use super::*;
use crate::input::keybindings::Action;
use crate::model::event::{ContainerId, CursorId, LeafId, SplitDirection};
use crate::services::plugins::hooks::HookArgs;
use crate::view::popup_mouse::{popup_areas_to_layout_info, PopupHitTester};
use crate::view::prompt::PromptType;
use crate::view::ui::tabs::TabHit;
use anyhow::Result as AnyhowResult;
use ratatui::layout::Rect;
use rust_i18n::t;
fn in_rect(col: u16, row: u16, rect: Rect) -> bool {
col >= rect.x && col < rect.x + rect.width && row >= rect.y && row < rect.y + rect.height
}
impl Editor {
pub fn handle_mouse(
&mut self,
mouse_event: crossterm::event::MouseEvent,
) -> AnyhowResult<bool> {
use crossterm::event::{MouseButton, MouseEventKind};
let col = mouse_event.column;
let row = mouse_event.row;
let (is_double_click, is_triple_click) = self.detect_multi_click(&mouse_event, col, row);
if self.keybinding_editor.is_some() {
return self.handle_keybinding_editor_mouse(mouse_event);
}
if self.settings_state.as_ref().is_some_and(|s| s.visible) {
return self.handle_settings_mouse(mouse_event, is_double_click);
}
if self.calibration_wizard.is_some() {
return Ok(false);
}
if self.global_popups.top().is_some_and(|p| {
matches!(
p.resolver,
crate::view::popup::PopupResolver::WorkspaceTrust
)
}) {
return self.handle_workspace_trust_mouse(mouse_event);
}
let mut needs_render = false;
if let Some(ref prompt) = self.active_window_mut().prompt {
if matches!(prompt.prompt_type, PromptType::LspRename { .. }) {
self.cancel_prompt();
needs_render = true;
}
}
let cursor_moved = self.active_window_mut().mouse_cursor_position != Some((col, row));
self.active_window_mut().mouse_cursor_position = Some((col, row));
if self.active_window_mut().gpm_active && cursor_moved {
needs_render = true;
}
tracing::trace!(
"handle_mouse: kind={:?}, col={}, row={}",
mouse_event.kind,
col,
row
);
if let Some(result) =
self.active_window_mut()
.try_forward_mouse_to_terminal(col, row, mouse_event)
{
return result;
}
if self.active_window_mut().theme_info_popup.is_some() {
if let MouseEventKind::Down(MouseButton::Left) = mouse_event.kind {
if let Some((popup_rect, button_row_offset)) = self.theme_info_popup_rect() {
if in_rect(col, row, popup_rect) {
let actual_button_row = popup_rect.y + button_row_offset;
if row == actual_button_row {
let fg_key = self
.active_window_mut()
.theme_info_popup
.as_ref()
.and_then(|p| p.info.fg_key.clone());
self.active_window_mut().theme_info_popup = None;
if let Some(key) = fg_key {
self.fire_theme_inspect_hook(key);
}
return Ok(true);
}
return Ok(true);
}
}
self.active_window_mut().theme_info_popup = None;
needs_render = true;
}
}
match mouse_event.kind {
MouseEventKind::Down(MouseButton::Left) => {
if is_double_click || is_triple_click {
if let Some((buffer_id, byte_pos)) =
self.fold_toggle_line_at_screen_position(col, row)
{
self.active_window_mut()
.toggle_fold_at_byte(buffer_id, byte_pos);
needs_render = true;
return Ok(needs_render);
}
}
if is_triple_click {
self.handle_mouse_triple_click(col, row)?;
needs_render = true;
return Ok(needs_render);
}
if is_double_click {
self.handle_mouse_double_click(col, row)?;
needs_render = true;
return Ok(needs_render);
}
self.handle_mouse_click(col, row, mouse_event.modifiers)?;
needs_render = true;
}
MouseEventKind::Drag(MouseButton::Left) => {
self.handle_mouse_drag(col, row)?;
needs_render = true;
}
MouseEventKind::Up(MouseButton::Left) => {
let was_dragging_separator = self
.active_window_mut()
.mouse_state
.dragging_separator
.is_some();
if let Some(drag_state) = self.active_window_mut().mouse_state.dragging_tab.take() {
if drag_state.is_dragging() {
if let Some(drop_zone) = drag_state.drop_zone {
self.execute_tab_drop(
drag_state.buffer_id,
drag_state.source_split_id,
drop_zone,
);
}
}
}
self.active_window_mut().mouse_state.dragging_scrollbar = None;
self.active_window_mut().mouse_state.drag_start_row = None;
self.active_window_mut().mouse_state.drag_start_top_byte = None;
self.active_window_mut()
.mouse_state
.dragging_horizontal_scrollbar = None;
self.active_window_mut().mouse_state.drag_start_hcol = None;
self.active_window_mut().mouse_state.drag_start_left_column = None;
self.active_window_mut().mouse_state.dragging_separator = None;
self.active_window_mut().mouse_state.drag_start_position = None;
self.active_window_mut().mouse_state.drag_start_ratio = None;
self.active_window_mut().mouse_state.dragging_file_explorer = false;
self.active_window_mut()
.mouse_state
.drag_start_explorer_width = None;
self.active_window_mut().mouse_state.dragging_text_selection = false;
self.active_window_mut().mouse_state.drag_selection_split = None;
self.active_window_mut().mouse_state.drag_selection_anchor = None;
self.active_window_mut().mouse_state.drag_selection_by_words = false;
self.active_window_mut().mouse_state.drag_selection_word_end = None;
self.active_window_mut()
.mouse_state
.dragging_popup_scrollbar = None;
self.active_window_mut().mouse_state.drag_start_popup_scroll = None;
self.active_window_mut()
.mouse_state
.dragging_prompt_scrollbar = false;
self.active_window_mut().mouse_state.selecting_in_popup = None;
if was_dragging_separator {
self.active_window_mut().resize_visible_terminals();
}
needs_render = true;
}
MouseEventKind::Moved => {
{
let content_rect = self
.active_layout()
.split_areas
.iter()
.find(|(_, _, content_rect, _, _, _)| in_rect(col, row, *content_rect))
.map(|(_, _, rect, _, _, _)| *rect);
let (content_x, content_y) = content_rect.map(|r| (r.x, r.y)).unwrap_or((0, 0));
self.plugin_manager.read().unwrap().run_hook(
"mouse_move",
HookArgs::MouseMove {
column: col,
row,
content_x,
content_y,
},
);
}
let hover_changed = self.update_hover_target(col, row);
needs_render = needs_render || hover_changed;
if let Some((popup_rect, button_row_offset)) = self.theme_info_popup_rect() {
let button_row = popup_rect.y + button_row_offset;
let new_highlighted = row == button_row
&& col >= popup_rect.x
&& col < popup_rect.x + popup_rect.width;
if let Some(ref mut popup) = self.active_window_mut().theme_info_popup {
if popup.button_highlighted != new_highlighted {
popup.button_highlighted = new_highlighted;
needs_render = true;
}
}
}
self.update_lsp_hover_state(col, row);
}
MouseEventKind::ScrollUp => {
self.handle_vertical_scroll(col, row, mouse_event.modifiers, -3)?;
needs_render = true;
}
MouseEventKind::ScrollDown => {
self.handle_vertical_scroll(col, row, mouse_event.modifiers, 3)?;
needs_render = true;
}
MouseEventKind::ScrollLeft => {
self.active_window_mut()
.handle_horizontal_scroll(col, row, -3)?;
needs_render = true;
}
MouseEventKind::ScrollRight => {
self.active_window_mut()
.handle_horizontal_scroll(col, row, 3)?;
needs_render = true;
}
MouseEventKind::Down(MouseButton::Right) => {
if mouse_event
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL)
{
self.show_theme_info_popup(col, row)?;
} else {
self.handle_right_click(col, row)?;
}
needs_render = true;
}
_ => {
}
}
self.active_window_mut().mouse_state.last_position = Some((col, row));
Ok(needs_render)
}
fn detect_multi_click(
&mut self,
mouse_event: &crossterm::event::MouseEvent,
col: u16,
row: u16,
) -> (bool, bool) {
use crossterm::event::{MouseButton, MouseEventKind};
if !matches!(mouse_event.kind, MouseEventKind::Down(MouseButton::Left)) {
return (false, false);
}
let now = self.time_source.now();
let threshold = std::time::Duration::from_millis(self.config.editor.double_click_time_ms);
let is_consecutive = if let (Some(prev_time), Some(prev_pos)) = (
self.active_window_mut().previous_click_time,
self.active_window_mut().previous_click_position,
) {
now.duration_since(prev_time) < threshold && prev_pos == (col, row)
} else {
false
};
if is_consecutive {
self.active_window_mut().click_count += 1;
} else {
self.active_window_mut().click_count = 1;
}
self.active_window_mut().previous_click_time = Some(now);
self.active_window_mut().previous_click_position = Some((col, row));
let is_triple = self.active_window_mut().click_count >= 3;
let is_double = self.active_window_mut().click_count == 2;
if is_triple {
self.active_window_mut().click_count = 0;
self.active_window_mut().previous_click_time = None;
self.active_window_mut().previous_click_position = None;
}
(is_double, is_triple)
}
fn handle_vertical_scroll(
&mut self,
col: u16,
row: u16,
modifiers: crossterm::event::KeyModifiers,
delta: i32,
) -> AnyhowResult<()> {
if modifiers.contains(crossterm::event::KeyModifiers::SHIFT) {
self.active_window_mut()
.handle_horizontal_scroll(col, row, delta)?;
} else if self.handle_prompt_scroll(delta) {
} else if self.is_file_open_active()
&& self.is_mouse_over_file_browser(col, row)
&& self.handle_file_open_scroll(delta)
{
} else if self.is_mouse_over_any_popup(col, row) {
self.scroll_popup(delta);
} else if self.handle_floating_widget_panel_wheel(col, row, delta) {
} else if self
.active_window()
.split_at_position(col, row)
.map(|(_, buffer_id)| self.handle_widget_panel_wheel(buffer_id, delta))
.unwrap_or(false)
{
} else {
if self.active_window().terminal_mode
&& self
.active_window()
.is_terminal_buffer(self.active_buffer())
{
{
let __b = self.active_buffer();
self.active_window_mut().sync_terminal_to_buffer(__b);
};
self.active_window_mut().terminal_mode = false;
self.active_window_mut().key_context =
crate::input::keybindings::KeyContext::Normal;
}
self.dismiss_transient_popups();
self.active_window_mut()
.handle_mouse_scroll(col, row, delta)?;
}
Ok(())
}
pub(super) fn update_hover_target(&mut self, col: u16, row: u16) -> bool {
let old_target = self.active_window_mut().mouse_state.hover_target.clone();
let new_target = self.compute_hover_target(col, row);
let changed = old_target != new_target;
self.active_window_mut().mouse_state.hover_target = new_target.clone();
if let Some(active_menu_idx) = self.menu_state.active_menu {
let all_menus: Vec<crate::config::Menu> = self
.menus
.menus
.iter()
.chain(self.menu_state.plugin_menus.iter())
.cloned()
.collect();
if let Some(HoverTarget::MenuBarItem(hovered_menu_idx)) = new_target.clone() {
if hovered_menu_idx != active_menu_idx {
self.menu_state.open_menu(hovered_menu_idx);
return true; }
}
if let Some(HoverTarget::MenuDropdownItem(_, item_idx)) = new_target.clone() {
if self.menu_state.submenu_path.first() == Some(&item_idx) {
tracing::trace!(
"menu hover: staying on submenu parent item_idx={}, submenu_path={:?}",
item_idx,
self.menu_state.submenu_path
);
return changed;
}
if !self.menu_state.submenu_path.is_empty() {
tracing::trace!(
"menu hover: clearing submenu_path={:?} for different item_idx={}",
self.menu_state.submenu_path,
item_idx
);
self.menu_state.submenu_path.clear();
self.menu_state.highlighted_item = Some(item_idx);
return true;
}
if let Some(menu) = all_menus.get(active_menu_idx) {
if let Some(crate::config::MenuItem::Submenu { items, .. }) =
menu.items.get(item_idx)
{
if !items.is_empty() {
tracing::trace!("menu hover: opening submenu at item_idx={}", item_idx);
self.menu_state.submenu_path.push(item_idx);
self.menu_state.highlighted_item = Some(0);
return true;
}
}
}
if self.menu_state.highlighted_item != Some(item_idx) {
self.menu_state.highlighted_item = Some(item_idx);
return true;
}
}
if let Some(HoverTarget::SubmenuItem(depth, item_idx)) = new_target {
if self.menu_state.submenu_path.len() > depth
&& self.menu_state.submenu_path.get(depth) == Some(&item_idx)
{
tracing::trace!(
"menu hover: staying on nested submenu parent depth={}, item_idx={}, submenu_path={:?}",
depth,
item_idx,
self.menu_state.submenu_path
);
return changed;
}
if self.menu_state.submenu_path.len() > depth {
tracing::trace!(
"menu hover: truncating submenu_path={:?} to depth={} for item_idx={}",
self.menu_state.submenu_path,
depth,
item_idx
);
self.menu_state.submenu_path.truncate(depth);
}
if let Some(items) = self
.menu_state
.get_current_items(&all_menus, active_menu_idx)
{
if let Some(crate::config::MenuItem::Submenu {
items: sub_items, ..
}) = items.get(item_idx)
{
if !sub_items.is_empty()
&& !self.menu_state.submenu_path.contains(&item_idx)
{
tracing::trace!(
"menu hover: opening nested submenu at depth={}, item_idx={}",
depth,
item_idx
);
self.menu_state.submenu_path.push(item_idx);
self.menu_state.highlighted_item = Some(0);
return true;
}
}
if self.menu_state.highlighted_item != Some(item_idx) {
self.menu_state.highlighted_item = Some(item_idx);
return true;
}
}
}
}
if let Some(HoverTarget::TabContextMenuItem(item_idx)) = new_target.clone() {
if let Some(ref mut menu) = self.active_window_mut().tab_context_menu {
if menu.highlighted != item_idx {
menu.highlighted = item_idx;
return true;
}
}
}
if let Some(&HoverTarget::FileExplorerContextMenuItem(item_idx)) = new_target.as_ref() {
if let Some(ref mut menu) = self.active_window_mut().file_explorer_context_menu {
if menu.highlighted != item_idx {
menu.highlighted = item_idx;
return true;
}
}
}
if old_target != new_target
&& matches!(
old_target,
Some(HoverTarget::FileExplorerStatusIndicator(_))
)
{
self.dismiss_file_explorer_status_tooltip();
}
if let Some(HoverTarget::FileExplorerStatusIndicator(ref path)) = new_target {
if old_target != new_target {
self.show_file_explorer_status_tooltip(path.clone(), col, row);
return true;
}
}
changed
}
fn update_lsp_hover_state(&mut self, col: u16, row: u16) {
tracing::trace!(col, row, "update_lsp_hover_state: raw mouse position");
if self.active_window_mut().theme_info_popup.is_some()
|| self.active_window_mut().tab_context_menu.is_some()
|| self
.active_window_mut()
.file_explorer_context_menu
.is_some()
{
if self
.active_window_mut()
.mouse_state
.lsp_hover_state
.is_some()
{
self.active_window_mut().mouse_state.lsp_hover_state = None;
self.active_window_mut().mouse_state.lsp_hover_request_sent = false;
self.dismiss_transient_popups();
}
return;
}
if self.is_mouse_over_transient_popup(col, row) {
return;
}
let split_info = self
.active_layout()
.split_areas
.iter()
.find(|(_, _, content_rect, _, _, _)| in_rect(col, row, *content_rect))
.map(|(split_id, buffer_id, content_rect, _, _, _)| {
(*split_id, *buffer_id, *content_rect)
});
let Some((split_id, buffer_id, content_rect)) = split_info else {
if self
.active_window_mut()
.mouse_state
.lsp_hover_state
.is_some()
{
self.active_window_mut().mouse_state.lsp_hover_state = None;
self.active_window_mut().mouse_state.lsp_hover_request_sent = false;
self.dismiss_transient_popups();
}
return;
};
let cached_mappings = self
.active_layout()
.view_line_mappings
.get(&split_id)
.cloned();
let gutter_width = self
.buffers()
.get(&buffer_id)
.map(|s| s.margins.left_total_width() as u16)
.unwrap_or(0);
let fallback = self
.buffers()
.get(&buffer_id)
.map(|s| s.buffer.len())
.unwrap_or(0);
let compose_width = self
.windows
.get(&self.active_window)
.and_then(|w| w.buffers.splits())
.map(|(_, vs)| vs)
.expect("active window must have a populated split layout")
.get(&split_id)
.and_then(|vs| vs.compose_width);
let Some(byte_pos) = super::click_geometry::screen_to_buffer_position(
col,
row,
content_rect,
gutter_width,
&cached_mappings,
fallback,
false, compose_width,
) else {
if self
.active_window_mut()
.mouse_state
.lsp_hover_state
.is_some()
{
self.active_window_mut().mouse_state.lsp_hover_state = None;
self.active_window_mut().mouse_state.lsp_hover_request_sent = false;
}
return;
};
let content_col = col.saturating_sub(content_rect.x);
let text_col = content_col.saturating_sub(gutter_width) as usize;
let visual_row = row.saturating_sub(content_rect.y) as usize;
let line_info = cached_mappings
.as_ref()
.and_then(|mappings| mappings.get(visual_row))
.map(|line_mapping| {
(
line_mapping.visual_to_char.len(),
line_mapping.line_end_byte,
)
});
let is_past_line_end_or_empty = line_info
.map(|(line_len, _)| {
if line_len <= 1 {
return true;
}
text_col >= line_len
})
.unwrap_or(true);
tracing::trace!(
col,
row,
content_col,
text_col,
visual_row,
gutter_width,
byte_pos,
?line_info,
is_past_line_end_or_empty,
"update_lsp_hover_state: position check"
);
if is_past_line_end_or_empty {
tracing::trace!(
"update_lsp_hover_state: mouse past line end or empty line, clearing hover"
);
if self
.active_window_mut()
.mouse_state
.lsp_hover_state
.is_some()
{
self.active_window_mut().mouse_state.lsp_hover_state = None;
self.active_window_mut().mouse_state.lsp_hover_request_sent = false;
}
return;
}
if let Some((start, end)) = self.active_window_mut().hover.symbol_range() {
if byte_pos >= start && byte_pos < end {
return;
}
}
if let Some((old_pos, _, _, _)) = self.active_window_mut().mouse_state.lsp_hover_state {
if old_pos == byte_pos {
return;
}
}
self.active_window_mut().mouse_state.lsp_hover_state =
Some((byte_pos, std::time::Instant::now(), col, row));
self.active_window_mut().mouse_state.lsp_hover_request_sent = false;
}
fn is_mouse_over_transient_popup(&self, col: u16, row: u16) -> bool {
let layouts = popup_areas_to_layout_info(&self.active_chrome().popup_areas);
let hit_tester = PopupHitTester::new(&layouts, &self.active_state().popups);
hit_tester.is_over_transient_popup(col, row)
}
fn is_mouse_over_any_popup(&self, col: u16, row: u16) -> bool {
for (_, popup_area, _, _, _) in &self.active_chrome().global_popup_areas {
if in_rect(col, row, *popup_area) {
return true;
}
}
if let Some(outer) = self.active_chrome().suggestions_outer_area {
if in_rect(col, row, outer) {
return true;
}
}
let layouts = popup_areas_to_layout_info(&self.active_chrome().popup_areas);
let hit_tester = PopupHitTester::new(&layouts, &self.active_state().popups);
hit_tester.is_over_popup(col, row)
}
fn is_mouse_over_file_browser(&self, col: u16, row: u16) -> bool {
self.active_window()
.file_browser_layout
.as_ref()
.is_some_and(|layout| layout.contains(col, row))
}
fn compute_hover_target(&self, col: u16, row: u16) -> Option<HoverTarget> {
if let Some(ref menu) = self.active_window().file_explorer_context_menu {
let (menu_x, menu_y) = menu.clamped_position(
self.active_chrome().last_frame_width,
self.active_chrome().last_frame_height,
);
let menu_width = super::types::FILE_EXPLORER_CONTEXT_MENU_WIDTH;
let menu_height = menu.height();
if col >= menu_x
&& col < menu_x + menu_width
&& row > menu_y
&& row < menu_y + menu_height - 1
{
let item_idx = (row - menu_y - 1) as usize;
if item_idx < menu.items().len() {
return Some(HoverTarget::FileExplorerContextMenuItem(item_idx));
}
}
}
if let Some(ref menu) = self.active_window().tab_context_menu {
let menu_x = menu.position.0;
let menu_y = menu.position.1;
let menu_width = 22u16;
let items = super::types::TabContextMenuItem::all();
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 - 1
{
let item_idx = (row - menu_y - 1) as usize;
if item_idx < items.len() {
return Some(HoverTarget::TabContextMenuItem(item_idx));
}
}
}
if let Some((inner_rect, start_idx, _visible_count, total_count)) =
&self.active_chrome().suggestions_area
{
if in_rect(col, row, *inner_rect) {
let relative_row = (row - inner_rect.y) as usize;
let item_idx = start_idx + relative_row;
if item_idx < *total_count {
return Some(HoverTarget::SuggestionItem(item_idx));
}
}
}
for (popup_idx, _popup_rect, inner_rect, scroll_offset, num_items, _, _) in
self.active_chrome().popup_areas.iter().rev()
{
if in_rect(col, row, *inner_rect) && *num_items > 0 {
let relative_row = (row - inner_rect.y) as usize;
let item_idx = scroll_offset + relative_row;
if item_idx < *num_items {
return Some(HoverTarget::PopupListItem(*popup_idx, item_idx));
}
}
}
if self.is_file_open_active() {
if let Some(hover) = self.compute_file_browser_hover(col, row) {
return Some(hover);
}
}
if self.active_window().menu_bar_visible {
if let Some(ref menu_layout) = self.active_chrome().menu_layout {
if let Some(menu_idx) = menu_layout.menu_at(col, row) {
return Some(HoverTarget::MenuBarItem(menu_idx));
}
}
}
if let Some(active_idx) = self.menu_state.active_menu {
if let Some(hover) = self.compute_menu_dropdown_hover(col, row, active_idx) {
return Some(hover);
}
}
if let Some(explorer_area) = self.active_layout().file_explorer_area {
let close_button_x = explorer_area.x + explorer_area.width.saturating_sub(3);
if row == explorer_area.y
&& col >= close_button_x
&& col < explorer_area.x + explorer_area.width
{
return Some(HoverTarget::FileExplorerCloseButton);
}
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
&& row < content_end_y
&& col >= status_indicator_x
&& col < explorer_area.x + explorer_area.width.saturating_sub(1)
{
if let Some(explorer) = self.file_explorer().as_ref() {
let relative_row = row.saturating_sub(content_start_y) as usize;
let scroll_offset = explorer.get_scroll_offset();
let item_index = relative_row + scroll_offset;
let display_nodes = explorer.get_display_nodes();
if item_index < display_nodes.len() {
let (node_id, _indent) = display_nodes[item_index];
if let Some(node) = explorer.tree().get_node(node_id) {
return Some(HoverTarget::FileExplorerStatusIndicator(
node.entry.path.clone(),
));
}
}
}
}
let border_x = explorer_area.x + explorer_area.width.saturating_sub(1);
if col == border_x
&& row >= explorer_area.y
&& row < explorer_area.y + explorer_area.height
{
return Some(HoverTarget::FileExplorerBorder);
}
}
for (split_id, direction, sep_x, sep_y, sep_length) in &self.active_layout().separator_areas
{
let is_on_separator = match direction {
SplitDirection::Horizontal => {
row == *sep_y && col >= *sep_x && col < sep_x + sep_length
}
SplitDirection::Vertical => {
col == *sep_x && row >= *sep_y && row < sep_y + sep_length
}
};
if is_on_separator {
return Some(HoverTarget::SplitSeparator(*split_id, *direction));
}
}
for (split_id, btn_row, start_col, end_col) in &self.active_layout().close_split_areas {
if row == *btn_row && col >= *start_col && col < *end_col {
return Some(HoverTarget::CloseSplitButton(*split_id));
}
}
for (split_id, btn_row, start_col, end_col) in &self.active_layout().maximize_split_areas {
if row == *btn_row && col >= *start_col && col < *end_col {
return Some(HoverTarget::MaximizeSplitButton(*split_id));
}
}
for (split_id, tab_layout) in &self.active_layout().tab_layouts {
match tab_layout.hit_test(col, row) {
Some(TabHit::CloseButton(target)) => {
return Some(HoverTarget::TabCloseButton(target, *split_id));
}
Some(TabHit::TabName(target)) => {
return Some(HoverTarget::TabName(target, *split_id));
}
Some(TabHit::ScrollLeft)
| Some(TabHit::ScrollRight)
| Some(TabHit::BarBackground)
| None => {}
}
}
for (split_id, _buffer_id, _content_rect, scrollbar_rect, thumb_start, thumb_end) in
&self.active_layout().split_areas
{
if in_rect(col, row, *scrollbar_rect) {
let relative_row = row.saturating_sub(scrollbar_rect.y) as usize;
let is_on_thumb = relative_row >= *thumb_start && relative_row < *thumb_end;
if is_on_thumb {
return Some(HoverTarget::ScrollbarThumb(*split_id));
} else {
return Some(HoverTarget::ScrollbarTrack(*split_id, relative_row as u16));
}
}
}
if let Some((status_row, _status_x, _status_width)) = self.active_chrome().status_bar_area {
if row == status_row {
let indicators = [
(
self.active_chrome().status_bar_line_ending_area,
HoverTarget::StatusBarLineEndingIndicator,
),
(
self.active_chrome().status_bar_encoding_area,
HoverTarget::StatusBarEncodingIndicator,
),
(
self.active_chrome().status_bar_language_area,
HoverTarget::StatusBarLanguageIndicator,
),
(
self.active_chrome().status_bar_lsp_area,
HoverTarget::StatusBarLspIndicator,
),
(
self.active_chrome().status_bar_remote_area,
HoverTarget::StatusBarRemoteIndicator,
),
(
self.active_chrome().status_bar_warning_area,
HoverTarget::StatusBarWarningBadge,
),
];
for (area, target) in indicators {
if let Some((indicator_row, start, end)) = area {
if row == indicator_row && col >= start && col < end {
return Some(target);
}
}
}
}
}
if let Some(ref layout) = self.active_chrome().search_options_layout {
use crate::view::ui::status_bar::SearchOptionsHover;
if let Some(hover) = layout.checkbox_at(col, row) {
return Some(match hover {
SearchOptionsHover::CaseSensitive => HoverTarget::SearchOptionCaseSensitive,
SearchOptionsHover::WholeWord => HoverTarget::SearchOptionWholeWord,
SearchOptionsHover::Regex => HoverTarget::SearchOptionRegex,
SearchOptionsHover::ConfirmEach => HoverTarget::SearchOptionConfirmEach,
SearchOptionsHover::None => return None,
});
}
}
None
}
pub(super) fn handle_mouse_double_click(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
tracing::debug!("handle_mouse_double_click at col={}, row={}", col, row);
if let Some(r) = self.handle_click_suggestions_confirm(col, row) {
return r;
}
if self.is_mouse_over_any_popup(col, row) {
return Ok(());
} else {
self.dismiss_transient_popups();
}
if self.handle_file_open_double_click(col, row) {
return Ok(());
}
if let Some(explorer_area) = self.active_layout().file_explorer_area {
if col >= explorer_area.x
&& col < explorer_area.x + explorer_area.width
&& row > explorer_area.y && row < explorer_area.y + explorer_area.height
{
self.file_explorer_open_file()?;
return Ok(());
}
}
let split_areas = self.active_layout().split_areas.clone();
for (split_id, buffer_id, content_rect, _scrollbar_rect, _thumb_start, _thumb_end) in
&split_areas
{
if in_rect(col, row, *content_rect) {
if self.active_window().is_terminal_buffer(*buffer_id) {
self.active_window_mut().key_context =
crate::input::keybindings::KeyContext::Terminal;
return Ok(());
}
self.active_window_mut().key_context =
crate::input::keybindings::KeyContext::Normal;
self.handle_editor_double_click(col, row, *split_id, *buffer_id, *content_rect)?;
return Ok(());
}
}
Ok(())
}
fn handle_editor_double_click(
&mut self,
col: u16,
row: u16,
split_id: LeafId,
buffer_id: BufferId,
content_rect: ratatui::layout::Rect,
) -> AnyhowResult<()> {
use crate::model::event::Event;
if self.active_window().is_non_scrollable_buffer(buffer_id) {
return Ok(());
}
self.focus_split(split_id, buffer_id);
let cached_mappings = self
.active_layout()
.view_line_mappings
.get(&split_id)
.cloned();
let leaf_id = split_id;
let fallback = self
.windows
.get(&self.active_window)
.and_then(|w| w.buffers.splits())
.map(|(_, vs)| vs)
.expect("active window must have a populated split layout")
.get(&leaf_id)
.map(|vs| vs.viewport.top_byte)
.unwrap_or(0);
let compose_width = self
.windows
.get(&self.active_window)
.and_then(|w| w.buffers.splits())
.map(|(_, vs)| vs)
.expect("active window must have a populated split layout")
.get(&leaf_id)
.and_then(|vs| vs.compose_width);
let gutter_width = self
.active_window()
.buffers
.get(&buffer_id)
.map(|s| s.margins.left_total_width() as u16)
.unwrap_or(0);
let Some(target_position) = super::click_geometry::screen_to_buffer_position(
col,
row,
content_rect,
gutter_width,
&cached_mappings,
fallback,
true, compose_width,
) else {
return Ok(());
};
let primary_cursor_id = self
.active_window()
.buffers
.splits()
.and_then(|(_, vs)| vs.get(&leaf_id))
.map(|vs| vs.cursors.primary_id())
.unwrap_or(CursorId(0));
let event = Event::MoveCursor {
cursor_id: primary_cursor_id,
old_position: 0,
new_position: target_position,
old_anchor: None,
new_anchor: None,
old_sticky_column: 0,
new_sticky_column: 0,
};
if let Some(event_log) = self.active_window_mut().event_logs.get_mut(&buffer_id) {
event_log.append(event.clone());
}
self.active_window_mut()
.apply_event_to_buffer(buffer_id, leaf_id, &event);
self.handle_action(Action::SelectWord)?;
if let Some(cursor) = self
.windows
.get(&self.active_window)
.and_then(|w| w.buffers.splits())
.map(|(_, vs)| vs)
.expect("active window must have a populated split layout")
.get(&leaf_id)
.map(|vs| vs.cursors.primary())
{
let sel_start = cursor.selection_start();
let sel_end = cursor.selection_end();
self.active_window_mut().mouse_state.dragging_text_selection = true;
self.active_window_mut().mouse_state.drag_selection_split = Some(split_id);
self.active_window_mut().mouse_state.drag_selection_anchor = Some(sel_start);
self.active_window_mut().mouse_state.drag_selection_by_words = true;
self.active_window_mut().mouse_state.drag_selection_word_end = Some(sel_end);
}
Ok(())
}
pub(super) fn handle_mouse_triple_click(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
tracing::debug!("handle_mouse_triple_click at col={}, row={}", col, row);
if self.is_mouse_over_any_popup(col, row) {
return Ok(());
} else {
self.dismiss_transient_popups();
}
let split_areas = self.active_layout().split_areas.clone();
for (split_id, buffer_id, content_rect, _scrollbar_rect, _thumb_start, _thumb_end) in
&split_areas
{
if in_rect(col, row, *content_rect) {
if self.active_window().is_terminal_buffer(*buffer_id) {
return Ok(());
}
self.active_window_mut().key_context =
crate::input::keybindings::KeyContext::Normal;
self.handle_editor_triple_click(col, row, *split_id, *buffer_id, *content_rect)?;
return Ok(());
}
}
Ok(())
}
fn handle_editor_triple_click(
&mut self,
col: u16,
row: u16,
split_id: LeafId,
buffer_id: BufferId,
content_rect: ratatui::layout::Rect,
) -> AnyhowResult<()> {
use crate::model::event::Event;
if self.active_window().is_non_scrollable_buffer(buffer_id) {
return Ok(());
}
self.focus_split(split_id, buffer_id);
let cached_mappings = self
.active_layout()
.view_line_mappings
.get(&split_id)
.cloned();
let leaf_id = split_id;
let fallback = self
.windows
.get(&self.active_window)
.and_then(|w| w.buffers.splits())
.map(|(_, vs)| vs)
.expect("active window must have a populated split layout")
.get(&leaf_id)
.map(|vs| vs.viewport.top_byte)
.unwrap_or(0);
let compose_width = self
.windows
.get(&self.active_window)
.and_then(|w| w.buffers.splits())
.map(|(_, vs)| vs)
.expect("active window must have a populated split layout")
.get(&leaf_id)
.and_then(|vs| vs.compose_width);
let gutter_width = self
.active_window()
.buffers
.get(&buffer_id)
.map(|s| s.margins.left_total_width() as u16)
.unwrap_or(0);
let Some(target_position) = super::click_geometry::screen_to_buffer_position(
col,
row,
content_rect,
gutter_width,
&cached_mappings,
fallback,
true,
compose_width,
) else {
return Ok(());
};
let primary_cursor_id = self
.active_window()
.buffers
.splits()
.and_then(|(_, vs)| vs.get(&leaf_id))
.map(|vs| vs.cursors.primary_id())
.unwrap_or(CursorId(0));
let event = Event::MoveCursor {
cursor_id: primary_cursor_id,
old_position: 0,
new_position: target_position,
old_anchor: None,
new_anchor: None,
old_sticky_column: 0,
new_sticky_column: 0,
};
if let Some(event_log) = self.active_window_mut().event_logs.get_mut(&buffer_id) {
event_log.append(event.clone());
}
self.active_window_mut()
.apply_event_to_buffer(buffer_id, leaf_id, &event);
self.handle_action(Action::SelectLine)?;
Ok(())
}
pub(super) fn handle_mouse_click(
&mut self,
col: u16,
row: u16,
modifiers: crossterm::event::KeyModifiers,
) -> AnyhowResult<()> {
if self.floating_widget_panel.is_some() {
self.handle_floating_widget_click(col, row);
return Ok(());
}
if let Some(r) = self.handle_click_context_menus(col, row) {
return r;
}
if !self.is_mouse_over_any_popup(col, row) {
self.dismiss_transient_popups();
}
if let Some(r) = self.handle_click_suggestions(col, row) {
return r;
}
if let Some(r) = self.handle_click_prompt_scrollbar(col, row) {
return r;
}
if let Some(r) = self.handle_click_popup_scrollbar(col, row) {
return r;
}
if let Some(r) = self.handle_click_global_popups(col, row) {
return r;
}
if let Some(r) = self.handle_click_buffer_popups(col, row) {
return r;
}
if self.is_mouse_over_any_popup(col, row) {
return Ok(());
}
if self.is_file_open_active() && self.handle_file_open_click(col, row) {
return Ok(());
}
if let Some(r) = self.handle_click_menu_bar(col, row) {
return r;
}
if let Some(r) = self.handle_click_file_explorer_area(col, row) {
return r;
}
if let Some(r) = self.handle_click_scrollbar(col, row) {
return r;
}
if let Some(r) = self.handle_click_horizontal_scrollbar(col, row) {
return r;
}
if let Some(r) = self.handle_click_status_bar(col, row) {
return r;
}
if let Some(r) = self.handle_click_search_options(col, row) {
return r;
}
if let Some(r) = self.handle_click_split_separator(col, row) {
return r;
}
if let Some(r) = self.handle_click_split_controls(col, row) {
return r;
}
if let Some(r) = self.handle_click_tab_bar(col, row) {
return r;
}
tracing::debug!(
"handle_mouse_click: checking {} split_areas for click at ({}, {})",
self.active_layout().split_areas.len(),
col,
row
);
for (split_id, buffer_id, content_rect, _scrollbar_rect, _thumb_start, _thumb_end) in
&self.active_layout().split_areas
{
tracing::debug!(
" split_id={:?}, content_rect=({}, {}, {}x{})",
split_id,
content_rect.x,
content_rect.y,
content_rect.width,
content_rect.height
);
if in_rect(col, row, *content_rect) {
tracing::debug!(" -> HIT! calling handle_editor_click");
self.handle_editor_click(
col,
row,
*split_id,
*buffer_id,
*content_rect,
modifiers,
)?;
return Ok(());
}
}
tracing::debug!(" -> No split area hit");
Ok(())
}
fn handle_click_context_menus(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
if self
.active_window_mut()
.file_explorer_context_menu
.is_some()
{
if let Some(result) = self.handle_file_explorer_context_menu_click(col, row) {
return Some(result);
}
}
if self.active_window_mut().tab_context_menu.is_some() {
if let Some(result) = self.handle_tab_context_menu_click(col, row) {
return Some(result);
}
}
None
}
fn suggestion_at(&self, col: u16, row: u16) -> Option<usize> {
let (inner_rect, start_idx, _visible_count, total_count) =
self.active_chrome().suggestions_area?;
if col < inner_rect.x
|| col >= inner_rect.x + inner_rect.width
|| row < inner_rect.y
|| row >= inner_rect.y + inner_rect.height
{
return None;
}
let relative_row = (row - inner_rect.y) as usize;
let item_idx = start_idx + relative_row;
if item_idx < total_count {
Some(item_idx)
} else {
None
}
}
fn handle_click_suggestions(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
let item_idx = self.suggestion_at(col, row)?;
let prompt = self.active_window_mut().prompt.as_mut()?;
prompt.selected_suggestion = Some(item_idx);
let confirms = prompt.prompt_type.click_confirms();
if !confirms {
if let Some(suggestion) = prompt.suggestions.get(item_idx) {
prompt.input = suggestion.get_value().to_string();
prompt.cursor_pos = prompt.input.len();
}
}
if confirms {
return Some(self.handle_action(Action::PromptConfirm));
}
Some(Ok(()))
}
fn handle_click_suggestions_confirm(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
let item_idx = self.suggestion_at(col, row)?;
let prompt = self.active_window_mut().prompt.as_mut()?;
prompt.selected_suggestion = Some(item_idx);
if let Some(suggestion) = prompt.suggestions.get(item_idx) {
prompt.input = suggestion.get_value().to_string();
prompt.cursor_pos = prompt.input.len();
}
Some(self.handle_action(Action::PromptConfirm))
}
fn handle_click_prompt_scrollbar(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
use crate::view::ui::scrollbar::ScrollbarState;
let sb_rect = self.active_chrome().suggestions_scrollbar_rect?;
if col < sb_rect.x
|| col >= sb_rect.x + sb_rect.width
|| row < sb_rect.y
|| row >= sb_rect.y + sb_rect.height
{
return None;
}
let suggestions_area_visible = self.active_chrome().suggestions_area.map(|(_, _, v, _)| v);
let active_window_id = self.active_window;
let prompt = self
.windows
.get_mut(&active_window_id)
.and_then(|w| w.prompt.as_mut())?;
let visible = suggestions_area_visible.unwrap_or(prompt.suggestions.len().min(10));
let total = prompt.suggestions.len();
let track_height = sb_rect.height as usize;
let click_row = row.saturating_sub(sb_rect.y) as usize;
let state = ScrollbarState::new(total, visible, prompt.scroll_offset);
prompt.scroll_offset = state.click_to_offset(track_height, click_row);
self.active_window_mut()
.mouse_state
.dragging_prompt_scrollbar = true;
Some(Ok(()))
}
fn handle_click_popup_scrollbar(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
let scrollbar_info: Option<(usize, i32)> =
self.active_chrome().popup_areas.iter().rev().find_map(
|(popup_idx, _popup_rect, inner_rect, _scroll, _n, scrollbar_rect, total_lines)| {
let sb_rect = scrollbar_rect.as_ref()?;
if col >= sb_rect.x
&& col < sb_rect.x + sb_rect.width
&& row >= sb_rect.y
&& row < sb_rect.y + sb_rect.height
{
let relative_row = (row - sb_rect.y) as usize;
let track_height = sb_rect.height as usize;
let visible_lines = inner_rect.height as usize;
if track_height > 0 && *total_lines > visible_lines {
let max_scroll = total_lines.saturating_sub(visible_lines);
let target = if track_height > 1 {
(relative_row * max_scroll) / (track_height.saturating_sub(1))
} else {
0
};
Some((*popup_idx, target as i32))
} else {
Some((*popup_idx, 0))
}
} else {
None
}
},
);
let (popup_idx, target_scroll) = scrollbar_info?;
self.active_window_mut()
.mouse_state
.dragging_popup_scrollbar = Some(popup_idx);
self.active_window_mut().mouse_state.drag_start_row = Some(row);
let current_scroll = self
.active_state()
.popups
.get(popup_idx)
.map(|p| p.scroll_offset)
.unwrap_or(0);
self.active_window_mut().mouse_state.drag_start_popup_scroll = Some(current_scroll);
let state = self.active_state_mut();
if let Some(popup) = state.popups.get_mut(popup_idx) {
popup.scroll_by(target_scroll - current_scroll as i32);
}
Some(Ok(()))
}
fn handle_workspace_trust_mouse(
&mut self,
mouse_event: crossterm::event::MouseEvent,
) -> AnyhowResult<bool> {
use crossterm::event::{MouseButton, MouseEventKind};
let col = mouse_event.column;
let row = mouse_event.row;
let layout = self.active_chrome().workspace_trust_dialog.clone();
match mouse_event.kind {
MouseEventKind::ScrollUp => {
self.workspace_trust_scroll = self.workspace_trust_scroll.saturating_sub(2);
}
MouseEventKind::ScrollDown => {
let max = layout.as_ref().map(|l| l.max_scroll).unwrap_or(0);
self.workspace_trust_scroll = (self.workspace_trust_scroll + 2).min(max);
}
MouseEventKind::Down(MouseButton::Left) => {
if let Some(layout) = layout {
let hit = |r: ratatui::layout::Rect| in_rect(col, row, r);
if hit(layout.ok) {
let idx = self.current_workspace_trust_selection();
self.confirm_workspace_trust(idx);
} else if hit(layout.quit) {
self.hide_popup();
if !self.workspace_trust_prompt_cancellable {
self.should_quit = true;
}
} else if let Some(i) = layout.radios.iter().position(|r| hit(*r)) {
self.confirm_workspace_trust(i);
}
}
}
_ => {}
}
Ok(true)
}
fn handle_click_global_popups(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
for (popup_idx, popup_rect, inner_rect, scroll_offset, num_items) in self
.active_chrome()
.global_popup_areas
.clone()
.into_iter()
.rev()
{
if popup_rect.width >= 5 {
let cb_x = popup_rect.x + popup_rect.width - 4;
if row == popup_rect.y && col >= cb_x && col < cb_x + 3 {
return Some(self.handle_action(Action::PopupCancel));
}
}
if in_rect(col, row, inner_rect) && num_items > 0 {
let relative_row = (row - inner_rect.y) as usize;
let item_idx = scroll_offset + relative_row;
if item_idx < num_items {
if let Some(popup) = self.global_popups.get_mut(popup_idx) {
if let crate::view::popup::PopupContent::List { items: _, selected } =
&mut popup.content
{
*selected = item_idx;
}
}
return Some(self.handle_action(Action::PopupConfirm));
}
}
}
None
}
fn handle_click_buffer_popups(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
let close_hit = self.active_chrome().popup_areas.iter().rev().find_map(
|(_idx, popup_rect, _inner, _scroll, _n, _sb, _tl)| {
if popup_rect.width < 5 {
return None;
}
let cb_x = popup_rect.x + popup_rect.width - 4;
if row == popup_rect.y && col >= cb_x && col < cb_x + 3 {
Some(())
} else {
None
}
},
);
if close_hit.is_some() {
return Some(self.handle_action(Action::PopupCancel));
}
let popup_areas = self.active_chrome().popup_areas.clone();
for (popup_idx, _popup_rect, inner_rect, scroll_offset, num_items, _, _) in
popup_areas.iter().rev()
{
if !in_rect(col, row, *inner_rect) {
continue;
}
let relative_col = (col - inner_rect.x) as usize;
let relative_row = (row - inner_rect.y) as usize;
let link_url = {
let state = self.active_state();
state
.popups
.top()
.and_then(|p| p.link_at_position(relative_col, relative_row))
};
if let Some(url) = link_url {
#[cfg(feature = "runtime")]
if let Err(e) = open::that(&url) {
self.set_status_message(format!("Failed to open URL: {}", e));
} else {
self.set_status_message(format!("Opening: {}", url));
}
return Some(Ok(()));
}
if *num_items > 0 {
let item_idx = scroll_offset + relative_row;
if item_idx < *num_items {
let state = self.active_state_mut();
if let Some(popup) = state.popups.top_mut() {
if let crate::view::popup::PopupContent::List { items: _, selected } =
&mut popup.content
{
*selected = item_idx;
}
}
return Some(self.handle_action(Action::PopupConfirm));
}
}
let is_text_popup = {
let state = self.active_state();
state.popups.top().is_some_and(|p| {
matches!(
p.content,
crate::view::popup::PopupContent::Text(_)
| crate::view::popup::PopupContent::Markdown(_)
)
})
};
if is_text_popup {
let line = scroll_offset + relative_row;
let popup_idx_copy = *popup_idx;
let state = self.active_state_mut();
if let Some(popup) = state.popups.top_mut() {
popup.start_selection(line, relative_col);
}
self.active_window_mut().mouse_state.selecting_in_popup = Some(popup_idx_copy);
return Some(Ok(()));
}
}
None
}
fn handle_click_menu_bar(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
if self.active_window_mut().menu_bar_visible {
let hit = self
.active_chrome()
.menu_layout
.as_ref()
.and_then(|ml| ml.menu_at(col, row));
let layout_exists = self.active_chrome().menu_layout.is_some();
if layout_exists {
if let Some(menu_idx) = hit {
if self.menu_state.active_menu == Some(menu_idx) {
self.close_menu_with_auto_hide();
} else {
self.active_window_mut().on_editor_focus_lost();
self.menu_state.open_menu(menu_idx);
}
return Some(Ok(()));
} else if row == 0 {
self.close_menu_with_auto_hide();
return Some(Ok(()));
}
}
}
if let Some(active_idx) = self.menu_state.active_menu {
let all_menus: Vec<crate::config::Menu> = self
.menus
.menus
.iter()
.chain(self.menu_state.plugin_menus.iter())
.cloned()
.collect();
if let Some(menu) = all_menus.get(active_idx) {
match self.handle_menu_dropdown_click(col, row, menu) {
Ok(Some(click_result)) => return Some(click_result),
Ok(None) => {}
Err(e) => return Some(Err(e)),
}
}
self.close_menu_with_auto_hide();
return Some(Ok(()));
}
None
}
fn handle_click_file_explorer_area(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
let explorer_area = self.active_layout().file_explorer_area?;
let border_x = explorer_area.x + explorer_area.width.saturating_sub(1);
if col == border_x && row >= explorer_area.y && row < explorer_area.y + explorer_area.height
{
self.active_window_mut().mouse_state.dragging_file_explorer = true;
self.active_window_mut().mouse_state.drag_start_position = Some((col, row));
self.active_window_mut()
.mouse_state
.drag_start_explorer_width = Some(self.active_window().file_explorer_width);
return Some(Ok(()));
}
if in_rect(col, row, explorer_area) {
return Some(self.handle_file_explorer_click(col, row, explorer_area));
}
None
}
fn handle_click_scrollbar(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
let (split_id, buffer_id, scrollbar_rect, is_on_thumb) =
self.active_layout().split_areas.iter().find_map(
|(split_id, buffer_id, _content, scrollbar_rect, thumb_start, thumb_end)| {
if in_rect(col, row, *scrollbar_rect) {
let relative_row = row.saturating_sub(scrollbar_rect.y) as usize;
let on_thumb = relative_row >= *thumb_start && relative_row < *thumb_end;
Some((*split_id, *buffer_id, *scrollbar_rect, on_thumb))
} else {
None
}
},
)?;
self.focus_split(split_id, buffer_id);
if is_on_thumb {
self.active_window_mut().mouse_state.dragging_scrollbar = Some(split_id);
self.active_window_mut().mouse_state.drag_start_row = Some(row);
if self.active_window().is_composite_buffer(buffer_id) {
if let Some(vs) = self
.active_window()
.composite_view_states
.get(&(split_id, buffer_id))
{
self.active_window_mut()
.mouse_state
.drag_start_composite_scroll_row = Some(vs.scroll_row);
}
} else {
let snap = self
.windows
.get(&self.active_window)
.and_then(|w| w.buffers.splits())
.map(|(_, vs)| vs)
.expect("active window must have a populated split layout")
.get(&split_id)
.map(|vs| (vs.viewport.top_byte, vs.viewport.top_view_line_offset));
if let Some((top_byte, top_view_line_offset)) = snap {
let ms = &mut self.active_window_mut().mouse_state;
ms.drag_start_top_byte = Some(top_byte);
ms.drag_start_view_line_offset = Some(top_view_line_offset);
}
}
} else {
self.active_window_mut().mouse_state.dragging_scrollbar = Some(split_id);
if let Err(e) = self.active_window_mut().handle_scrollbar_jump(
col,
row,
split_id,
buffer_id,
scrollbar_rect,
) {
return Some(Err(e));
}
self.active_window_mut().mouse_state.hover_target =
Some(HoverTarget::ScrollbarThumb(split_id));
}
Some(Ok(()))
}
fn handle_click_horizontal_scrollbar(
&mut self,
col: u16,
row: u16,
) -> Option<AnyhowResult<()>> {
let (split_id, buffer_id, hscrollbar_rect, max_content_width, is_on_thumb) = self
.active_layout()
.horizontal_scrollbar_areas
.iter()
.find_map(
|(
split_id,
buffer_id,
hscrollbar_rect,
max_content_width,
thumb_start,
thumb_end,
)| {
if col >= hscrollbar_rect.x
&& col < hscrollbar_rect.x + hscrollbar_rect.width
&& row >= hscrollbar_rect.y
&& row < hscrollbar_rect.y + hscrollbar_rect.height
{
let relative_col = col.saturating_sub(hscrollbar_rect.x) as usize;
let on_thumb = relative_col >= *thumb_start && relative_col < *thumb_end;
Some((
*split_id,
*buffer_id,
*hscrollbar_rect,
*max_content_width,
on_thumb,
))
} else {
None
}
},
)?;
self.focus_split(split_id, buffer_id);
self.active_window_mut()
.mouse_state
.dragging_horizontal_scrollbar = Some(split_id);
if is_on_thumb {
self.active_window_mut().mouse_state.drag_start_hcol = Some(col);
if let Some(vs) = self
.windows
.get(&self.active_window)
.and_then(|w| w.buffers.splits())
.map(|(_, vs)| vs)
.expect("active window must have a populated split layout")
.get(&split_id)
{
self.active_window_mut().mouse_state.drag_start_left_column =
Some(vs.viewport.left_column);
}
} else {
self.active_window_mut().mouse_state.drag_start_hcol = None;
self.active_window_mut().mouse_state.drag_start_left_column = None;
let relative_col = col.saturating_sub(hscrollbar_rect.x) as f64;
let track_width = hscrollbar_rect.width as f64;
let ratio = if track_width > 1.0 {
(relative_col / (track_width - 1.0)).clamp(0.0, 1.0)
} else {
0.0
};
if let Some(vs) = self
.windows
.get_mut(&self.active_window)
.and_then(|w| w.split_view_states_mut())
.expect("active window must have a populated split layout")
.get_mut(&split_id)
{
let visible_width = vs.viewport.width as usize;
let max_scroll = max_content_width.saturating_sub(visible_width);
let target_col = (ratio * max_scroll as f64).round() as usize;
vs.viewport.left_column = target_col.min(max_scroll);
vs.viewport.set_skip_ensure_visible();
}
}
Some(Ok(()))
}
fn handle_click_status_bar(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
let (status_row, _status_x, _status_width) = self.active_chrome().status_bar_area?;
if row != status_row {
return None;
}
if let Some((r, s, e)) = self.active_chrome().status_bar_line_ending_area {
if row == r && col >= s && col < e {
self.dismiss_menu_popups_for_prompt();
return Some(self.handle_action(Action::SetLineEnding));
}
}
if let Some((r, s, e)) = self.active_chrome().status_bar_encoding_area {
if row == r && col >= s && col < e {
self.dismiss_menu_popups_for_prompt();
return Some(self.handle_action(Action::SetEncoding));
}
}
if let Some((r, s, e)) = self.active_chrome().status_bar_language_area {
if row == r && col >= s && col < e {
self.dismiss_menu_popups_for_prompt();
return Some(self.handle_action(Action::SetLanguage));
}
}
if let Some((r, s, e)) = self.active_chrome().status_bar_lsp_area {
if row == r && col >= s && col < e {
return Some(self.handle_action(Action::ShowLspStatus));
}
}
if let Some((r, s, e)) = self.active_chrome().status_bar_remote_area {
if row == r && col >= s && col < e {
self.dismiss_menu_popups_for_prompt();
return Some(self.handle_action(Action::ShowRemoteIndicatorMenu));
}
}
if let Some((r, s, e)) = self.active_chrome().status_bar_warning_area {
if row == r && col >= s && col < e {
self.dismiss_menu_popups_for_prompt();
return Some(self.handle_action(Action::ShowWarnings));
}
}
if let Some((r, s, e)) = self.active_chrome().status_bar_message_area {
if row == r && col >= s && col < e {
return Some(self.handle_action(Action::ShowStatusLog));
}
}
None
}
fn handle_click_search_options(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
use crate::view::ui::status_bar::SearchOptionsHover;
let layout = self.active_chrome().search_options_layout.clone()?;
match layout.checkbox_at(col, row)? {
SearchOptionsHover::CaseSensitive => {
Some(self.handle_action(Action::ToggleSearchCaseSensitive))
}
SearchOptionsHover::WholeWord => {
Some(self.handle_action(Action::ToggleSearchWholeWord))
}
SearchOptionsHover::Regex => Some(self.handle_action(Action::ToggleSearchRegex)),
SearchOptionsHover::ConfirmEach => {
Some(self.handle_action(Action::ToggleSearchConfirmEach))
}
SearchOptionsHover::None => None,
}
}
fn handle_click_split_separator(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
let separator_areas = self.active_layout().separator_areas.clone();
for (split_id, direction, sep_x, sep_y, sep_length) in &separator_areas {
let is_on_separator = match direction {
SplitDirection::Horizontal => {
row == *sep_y && col >= *sep_x && col < sep_x + sep_length
}
SplitDirection::Vertical => {
col == *sep_x && row >= *sep_y && row < sep_y + sep_length
}
};
if is_on_separator {
self.active_window_mut().mouse_state.dragging_separator =
Some((*split_id, *direction));
self.active_window_mut().mouse_state.drag_start_position = Some((col, row));
let ratio = self
.split_manager_mut()
.get_ratio((*split_id).into())
.or_else(|| self.grouped_split_ratio(*split_id));
if let Some(ratio) = ratio {
self.active_window_mut().mouse_state.drag_start_ratio = Some(ratio);
}
return Some(Ok(()));
}
}
None
}
fn handle_click_split_controls(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
let close_split_id = self
.active_layout()
.close_split_areas
.iter()
.find(|(_, btn_row, start_col, end_col)| {
row == *btn_row && col >= *start_col && col < *end_col
})
.map(|(split_id, _, _, _)| *split_id);
if let Some(split_id) = close_split_id {
if let Err(e) = self
.windows
.get_mut(&self.active_window)
.and_then(|w| w.split_manager_mut())
.expect("active window must have a populated split layout")
.close_split(split_id)
{
self.set_status_message(
t!("error.cannot_close_split", error = e.to_string()).to_string(),
);
} else {
let new_active = self
.windows
.get(&self.active_window)
.and_then(|w| w.buffers.splits())
.map(|(mgr, _)| mgr)
.expect("active window must have a populated split layout")
.active_split();
if let Some(buffer_id) = self
.windows
.get(&self.active_window)
.and_then(|w| w.buffers.splits())
.map(|(mgr, _)| mgr)
.expect("active window must have a populated split layout")
.buffer_for_split(new_active)
{
self.set_active_buffer(buffer_id);
}
self.set_status_message(t!("split.closed").to_string());
}
return Some(Ok(()));
}
let maximize_target = self
.active_layout()
.maximize_split_areas
.iter()
.find(|(_, btn_row, start_col, end_col)| {
row == *btn_row && col >= *start_col && col < *end_col
})
.map(|(split_id, _, _, _)| *split_id);
if let Some(target) = maximize_target {
let already_maximized = self
.windows
.get(&self.active_window)
.and_then(|w| w.buffers.splits())
.map(|(mgr, _)| mgr.is_maximized())
.unwrap_or(false);
if !already_maximized {
if let Some(buffer_id) = self
.windows
.get(&self.active_window)
.and_then(|w| w.buffers.splits())
.map(|(mgr, _)| mgr)
.expect("active window must have a populated split layout")
.buffer_for_split(target)
{
self.focus_split(target, buffer_id);
}
}
match self
.windows
.get_mut(&self.active_window)
.and_then(|w| w.split_manager_mut())
.expect("active window must have a populated split layout")
.toggle_maximize_for(target)
{
Ok(maximized) => {
let msg = if maximized {
t!("split.maximized").to_string()
} else {
t!("split.restored").to_string()
};
self.set_status_message(msg);
}
Err(e) => self.set_status_message(e),
}
return Some(Ok(()));
}
None
}
fn handle_click_tab_bar(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
for (split_id, tab_layout) in &self.active_layout().tab_layouts {
tracing::debug!(
"Tab layout for split {:?}: bar_area={:?}, left_scroll={:?}, right_scroll={:?}",
split_id,
tab_layout.bar_area,
tab_layout.left_scroll_area,
tab_layout.right_scroll_area
);
}
let tab_hit = self
.active_layout()
.tab_layouts
.iter()
.find_map(|(split_id, tab_layout)| {
let hit = tab_layout.hit_test(col, row);
tracing::debug!(
"Tab hit_test at ({}, {}) for split {:?} returned {:?}",
col,
row,
split_id,
hit
);
hit.map(|h| (*split_id, h))
});
let (split_id, hit) = tab_hit?;
match hit {
TabHit::CloseButton(target) => {
match target {
crate::view::split::TabTarget::Buffer(buffer_id) => {
self.focus_split(split_id, buffer_id);
self.close_tab_in_split(buffer_id, split_id);
}
crate::view::split::TabTarget::Group(group_leaf) => {
self.close_buffer_group_by_leaf(group_leaf);
}
}
Some(Ok(()))
}
TabHit::TabName(target) => {
let direction = self
.windows
.get(&self.active_window)
.and_then(|w| w.buffers.splits())
.map(|(_, vs)| vs)
.expect("active window must have a populated split layout")
.get(&split_id)
.map(|vs| {
let open = &vs.open_buffers;
let cur = vs.active_target();
let cur_idx = open.iter().position(|t| *t == cur);
let new_idx = open.iter().position(|t| *t == target);
match (cur_idx, new_idx) {
(Some(c), Some(n)) if n > c => 1,
(Some(c), Some(n)) if n < c => -1,
_ => 0,
}
})
.unwrap_or(0);
self.active_window_mut()
.animate_tab_switch(split_id, direction);
match target {
crate::view::split::TabTarget::Buffer(buffer_id) => {
self.focus_split(split_id, buffer_id);
self.active_window_mut()
.promote_buffer_from_preview(buffer_id);
self.active_window_mut().mouse_state.dragging_tab = Some(
super::types::TabDragState::new(buffer_id, split_id, (col, row)),
);
}
crate::view::split::TabTarget::Group(group_leaf) => {
self.activate_group_tab(split_id, group_leaf);
}
}
Some(Ok(()))
}
TabHit::ScrollLeft => {
self.set_status_message("ScrollLeft clicked!".to_string());
if let Some(vs) = self
.windows
.get_mut(&self.active_window)
.and_then(|w| w.split_view_states_mut())
.expect("active window must have a populated split layout")
.get_mut(&split_id)
{
vs.tab_scroll_offset = vs.tab_scroll_offset.saturating_sub(10);
}
Some(Ok(()))
}
TabHit::ScrollRight => {
self.set_status_message("ScrollRight clicked!".to_string());
if let Some(vs) = self
.windows
.get_mut(&self.active_window)
.and_then(|w| w.split_view_states_mut())
.expect("active window must have a populated split layout")
.get_mut(&split_id)
{
vs.tab_scroll_offset = vs.tab_scroll_offset.saturating_add(10);
}
Some(Ok(()))
}
TabHit::BarBackground => None,
}
}
pub(super) fn handle_mouse_drag(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
if let Some(dragging_split_id) = self.active_window_mut().mouse_state.dragging_scrollbar {
let split_areas = self.active_layout().split_areas.clone();
for (split_id, buffer_id, _content_rect, scrollbar_rect, _thumb_start, _thumb_end) in
&split_areas
{
if *split_id == dragging_split_id {
if self.active_window().mouse_state.drag_start_row.is_some() {
self.active_window_mut().handle_scrollbar_drag_relative(
row,
*split_id,
*buffer_id,
*scrollbar_rect,
)?;
} else {
self.active_window_mut().handle_scrollbar_jump(
col,
row,
*split_id,
*buffer_id,
*scrollbar_rect,
)?;
}
return Ok(());
}
}
}
if let Some(dragging_split_id) = self
.active_window_mut()
.mouse_state
.dragging_horizontal_scrollbar
{
let hscrollbar_areas = self.active_layout().horizontal_scrollbar_areas.clone();
for (
split_id,
_buffer_id,
hscrollbar_rect,
max_content_width,
thumb_start,
thumb_end,
) in &hscrollbar_areas
{
if *split_id == dragging_split_id {
let track_width = hscrollbar_rect.width as f64;
if track_width <= 1.0 {
break;
}
if let (Some(drag_start_hcol), Some(drag_start_left_column)) = (
self.active_window_mut().mouse_state.drag_start_hcol,
self.active_window_mut().mouse_state.drag_start_left_column,
) {
let col_offset = (col as i32) - (drag_start_hcol as i32);
if let Some(view_state) = self
.windows
.get_mut(&self.active_window)
.and_then(|w| w.split_view_states_mut())
.expect("active window must have a populated split layout")
.get_mut(&dragging_split_id)
{
let visible_width = view_state.viewport.width as usize;
let max_scroll = max_content_width.saturating_sub(visible_width);
if max_scroll > 0 {
let thumb_size = thumb_end.saturating_sub(*thumb_start).max(1);
let track_travel = (track_width - thumb_size as f64).max(1.0);
let scroll_per_pixel = max_scroll as f64 / track_travel;
let scroll_offset =
(col_offset as f64 * scroll_per_pixel).round() as i64;
let new_left =
(drag_start_left_column as i64 + scroll_offset).max(0) as usize;
view_state.viewport.left_column = new_left.min(max_scroll);
view_state.viewport.set_skip_ensure_visible();
}
}
} else {
let relative_col = col.saturating_sub(hscrollbar_rect.x) as f64;
let ratio = (relative_col / (track_width - 1.0)).clamp(0.0, 1.0);
if let Some(view_state) = self
.windows
.get_mut(&self.active_window)
.and_then(|w| w.split_view_states_mut())
.expect("active window must have a populated split layout")
.get_mut(&dragging_split_id)
{
let visible_width = view_state.viewport.width as usize;
let max_scroll = max_content_width.saturating_sub(visible_width);
let target_col = (ratio * max_scroll as f64).round() as usize;
view_state.viewport.left_column = target_col.min(max_scroll);
view_state.viewport.set_skip_ensure_visible();
}
}
return Ok(());
}
}
}
if let Some(popup_idx) = self.active_window_mut().mouse_state.selecting_in_popup {
if let Some((_, _, inner_rect, scroll_offset, _, _, _)) = self
.active_chrome()
.popup_areas
.iter()
.find(|(idx, _, _, _, _, _, _)| *idx == popup_idx)
{
if col >= inner_rect.x
&& col < inner_rect.x + inner_rect.width
&& row >= inner_rect.y
&& row < inner_rect.y + inner_rect.height
{
let relative_col = (col - inner_rect.x) as usize;
let relative_row = (row - inner_rect.y) as usize;
let line = scroll_offset + relative_row;
let state = self.active_state_mut();
if let Some(popup) = state.popups.get_mut(popup_idx) {
popup.extend_selection(line, relative_col);
}
}
}
return Ok(());
}
if self
.active_window_mut()
.mouse_state
.dragging_prompt_scrollbar
{
use crate::view::ui::scrollbar::ScrollbarState;
let sb_rect = self.active_chrome().suggestions_scrollbar_rect;
let suggestions_area_visible =
self.active_chrome().suggestions_area.map(|(_, _, v, _)| v);
let active_window_id = self.active_window;
if let (Some(sb_rect), Some(prompt)) = (
sb_rect,
self.windows
.get_mut(&active_window_id)
.and_then(|w| w.prompt.as_mut()),
) {
let visible = suggestions_area_visible.unwrap_or(prompt.suggestions.len().min(10));
let total = prompt.suggestions.len();
let track_height = sb_rect.height as usize;
let clamped_row =
row.clamp(sb_rect.y, sb_rect.y + sb_rect.height.saturating_sub(1));
let click_row = clamped_row.saturating_sub(sb_rect.y) as usize;
let state = ScrollbarState::new(total, visible, prompt.scroll_offset);
prompt.scroll_offset = state.click_to_offset(track_height, click_row);
}
return Ok(());
}
if let Some(popup_idx) = self
.active_window_mut()
.mouse_state
.dragging_popup_scrollbar
{
if let Some((_, _, inner_rect, _, _, Some(sb_rect), total_lines)) = self
.active_chrome()
.popup_areas
.iter()
.find(|(idx, _, _, _, _, _, _)| *idx == popup_idx)
{
let track_height = sb_rect.height as usize;
let visible_lines = inner_rect.height as usize;
if track_height > 0 && *total_lines > visible_lines {
let relative_row = row.saturating_sub(sb_rect.y) as usize;
let max_scroll = total_lines.saturating_sub(visible_lines);
let target_scroll = if track_height > 1 {
(relative_row * max_scroll) / (track_height.saturating_sub(1))
} else {
0
};
let state = self.active_state_mut();
if let Some(popup) = state.popups.get_mut(popup_idx) {
let current_scroll = popup.scroll_offset as i32;
let delta = target_scroll as i32 - current_scroll;
popup.scroll_by(delta);
}
}
}
return Ok(());
}
if let Some((split_id, direction)) = self.active_window_mut().mouse_state.dragging_separator
{
self.handle_separator_drag(col, row, split_id, direction)?;
return Ok(());
}
if self.active_window_mut().mouse_state.dragging_file_explorer {
self.handle_file_explorer_border_drag(col)?;
return Ok(());
}
if self.active_window_mut().mouse_state.dragging_text_selection {
self.handle_text_selection_drag(col, row)?;
return Ok(());
}
if self.active_window_mut().mouse_state.dragging_tab.is_some() {
self.handle_tab_drag(col, row)?;
return Ok(());
}
Ok(())
}
fn handle_text_selection_drag(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
use crate::model::event::Event;
use crate::primitives::word_navigation::{find_word_end, find_word_start};
let Some(split_id) = self.active_window_mut().mouse_state.drag_selection_split else {
return Ok(());
};
let Some(anchor_position) = self.active_window_mut().mouse_state.drag_selection_anchor
else {
return Ok(());
};
let Some((buffer_id, content_rect)) = self
.active_layout()
.split_areas
.iter()
.find(|(sid, _, _, _, _, _)| *sid == split_id)
.map(|(_, bid, rect, _, _, _)| (*bid, *rect))
else {
return Ok(());
};
let cached_mappings = self
.active_layout()
.view_line_mappings
.get(&split_id)
.cloned();
let leaf_id = split_id;
let fallback = self
.windows
.get(&self.active_window)
.and_then(|w| w.buffers.splits())
.map(|(_, vs)| vs)
.expect("active window must have a populated split layout")
.get(&leaf_id)
.map(|vs| vs.viewport.top_byte)
.unwrap_or(0);
let compose_width = self
.windows
.get(&self.active_window)
.and_then(|w| w.buffers.splits())
.map(|(_, vs)| vs)
.expect("active window must have a populated split layout")
.get(&leaf_id)
.and_then(|vs| vs.compose_width);
let drag_by_words = self.active_window_mut().mouse_state.drag_selection_by_words;
let drag_word_end = self.active_window_mut().mouse_state.drag_selection_word_end;
let Some((target_position, new_position, anchor_position, new_sticky_column)) = self
.active_window()
.buffers
.get(&buffer_id)
.and_then(|state| {
let gutter_width = state.margins.left_total_width() as u16;
let target_position = super::click_geometry::screen_to_buffer_position(
col,
row,
content_rect,
gutter_width,
&cached_mappings,
fallback,
true, compose_width,
)?;
let (new_position, anchor_pos) = if drag_by_words {
if target_position >= anchor_position {
(
find_word_end(&state.buffer, target_position),
anchor_position,
)
} else {
let word_end = drag_word_end.unwrap_or(anchor_position);
(find_word_start(&state.buffer, target_position), word_end)
}
} else {
(target_position, anchor_position)
};
let new_sticky_column = state
.buffer
.offset_to_position(new_position)
.map(|pos| pos.column);
Some((target_position, new_position, anchor_pos, new_sticky_column))
})
else {
return Ok(());
};
let _ = target_position;
let (primary_cursor_id, old_position, old_anchor, old_sticky_column) = self
.active_window()
.buffers
.splits()
.and_then(|(_, vs)| vs.get(&leaf_id))
.map(|vs| {
let cursor = vs.cursors.primary();
(
vs.cursors.primary_id(),
cursor.position,
cursor.anchor,
cursor.sticky_column,
)
})
.unwrap_or((CursorId(0), 0, None, 0));
let event = Event::MoveCursor {
cursor_id: primary_cursor_id,
old_position,
new_position,
old_anchor,
new_anchor: Some(anchor_position),
old_sticky_column,
new_sticky_column: new_sticky_column.unwrap_or(old_sticky_column),
};
if let Some(event_log) = self.active_window_mut().event_logs.get_mut(&buffer_id) {
event_log.append(event.clone());
}
self.active_window_mut()
.apply_event_to_buffer(buffer_id, leaf_id, &event);
Ok(())
}
pub(super) fn handle_file_explorer_border_drag(&mut self, col: u16) -> AnyhowResult<()> {
let Some((start_col, _start_row)) =
self.active_window_mut().mouse_state.drag_start_position
else {
return Ok(());
};
let Some(start_width) = self
.active_window_mut()
.mouse_state
.drag_start_explorer_width
else {
return Ok(());
};
let delta = col as i32 - start_col as i32;
let total_width = self.terminal_width as i32;
if total_width > 0 {
use crate::config::ExplorerWidth;
self.active_window_mut().file_explorer_width = match start_width {
ExplorerWidth::Percent(start_pct) => {
let percent_delta = (delta * 100) / total_width;
let new_pct = (start_pct as i32 + percent_delta).clamp(0, 100) as u8;
ExplorerWidth::Percent(new_pct)
}
ExplorerWidth::Columns(start_cols) => {
let new_cols = (start_cols as i32 + delta).clamp(0, total_width) as u16;
ExplorerWidth::Columns(new_cols)
}
};
}
Ok(())
}
pub(super) fn handle_separator_drag(
&mut self,
col: u16,
row: u16,
split_id: ContainerId,
direction: SplitDirection,
) -> AnyhowResult<()> {
let Some((start_col, start_row)) = self.active_window_mut().mouse_state.drag_start_position
else {
return Ok(());
};
let Some(start_ratio) = self.active_window_mut().mouse_state.drag_start_ratio else {
return Ok(());
};
let Some(editor_area) = self.active_layout().editor_content_area else {
return Ok(());
};
let (delta, total_size) = match direction {
SplitDirection::Horizontal => {
let delta = row as i32 - start_row as i32;
let total = editor_area.height as i32;
(delta, total)
}
SplitDirection::Vertical => {
let delta = col as i32 - start_col as i32;
let total = editor_area.width as i32;
(delta, total)
}
};
if total_size > 0 {
let ratio_delta = delta as f32 / total_size as f32;
let new_ratio = (start_ratio + ratio_delta).clamp(0.1, 0.9);
if self
.windows
.get(&self.active_window)
.and_then(|w| w.buffers.splits())
.map(|(mgr, _)| mgr)
.expect("active window must have a populated split layout")
.get_ratio(split_id.into())
.is_some()
{
self.windows
.get_mut(&self.active_window)
.and_then(|w| w.split_manager_mut())
.expect("active window must have a populated split layout")
.set_ratio(split_id, new_ratio);
} else {
self.set_grouped_split_ratio(split_id, new_ratio);
}
}
Ok(())
}
pub(super) fn handle_right_click(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
let frame_w = self.active_chrome().last_frame_width;
let frame_h = self.active_chrome().last_frame_height;
if let Some(ref menu) = self.active_window().file_explorer_context_menu {
let (menu_x, menu_y) = menu.clamped_position(frame_w, frame_h);
let menu_width = super::types::FILE_EXPLORER_CONTEXT_MENU_WIDTH;
let menu_height = menu.height();
if col >= menu_x
&& col < menu_x + menu_width
&& row >= menu_y
&& row < menu_y + menu_height
{
return Ok(());
}
}
if let Some(ref menu) = self.active_window_mut().tab_context_menu {
let menu_x = menu.position.0;
let menu_y = menu.position.1;
let menu_width = 22u16; let menu_height = super::types::TabContextMenuItem::all().len() as u16 + 2;
if col >= menu_x
&& col < menu_x + menu_width
&& row >= menu_y
&& row < menu_y + menu_height
{
return Ok(());
}
}
if let Some(explorer_area) = self.active_layout().file_explorer_area {
if col >= explorer_area.x
&& col < explorer_area.x + explorer_area.width
&& row < explorer_area.y + explorer_area.height
&& row > explorer_area.y
{
let relative_row = row.saturating_sub(explorer_area.y + 1);
let (is_multi, is_root_selected) =
if let Some(explorer) = self.file_explorer_mut().as_mut() {
let display_nodes = explorer.get_display_nodes();
let scroll_offset = explorer.get_scroll_offset();
let clicked_index = (relative_row as usize) + scroll_offset;
let mut clicked_is_root = false;
if clicked_index < display_nodes.len() {
let (node_id, _) = display_nodes[clicked_index];
explorer.set_selected(Some(node_id));
clicked_is_root = node_id == explorer.tree().root_id();
}
(explorer.has_multi_selection(), clicked_is_root)
} else {
(false, false)
};
self.active_window_mut().key_context =
crate::input::keybindings::KeyContext::FileExplorer;
self.active_window_mut().tab_context_menu = None;
self.active_window_mut().file_explorer_context_menu =
Some(super::types::FileExplorerContextMenu::new(
col,
row + 1,
is_multi,
is_root_selected,
));
return Ok(());
}
}
self.active_window_mut().file_explorer_context_menu = None;
let tab_hit = self
.active_layout()
.tab_layouts
.iter()
.find_map(
|(split_id, tab_layout)| match tab_layout.hit_test(col, row) {
Some(TabHit::TabName(target) | TabHit::CloseButton(target)) => {
target.as_buffer().map(|bid| (*split_id, bid))
}
_ => None,
},
);
if let Some((split_id, buffer_id)) = tab_hit {
self.active_window_mut().tab_context_menu =
Some(TabContextMenu::new(buffer_id, split_id, col, row + 1));
} else {
self.active_window_mut().tab_context_menu = None;
}
Ok(())
}
pub(super) fn handle_tab_context_menu_click(
&mut self,
col: u16,
row: u16,
) -> Option<AnyhowResult<()>> {
let menu = self.active_window_mut().tab_context_menu.as_ref()?;
let menu_x = menu.position.0;
let menu_y = menu.position.1;
let menu_width = 22u16;
let items = super::types::TabContextMenuItem::all();
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
{
self.active_window_mut().tab_context_menu = None;
return Some(Ok(()));
}
if row == menu_y || row == menu_y + menu_height - 1 {
return Some(Ok(()));
}
let item_idx = (row - menu_y - 1) as usize;
if item_idx >= items.len() {
return Some(Ok(()));
}
let buffer_id = menu.buffer_id;
let split_id = menu.split_id;
let item = items[item_idx];
self.active_window_mut().tab_context_menu = None;
Some(self.execute_tab_context_menu_action(item, buffer_id, split_id))
}
fn execute_tab_context_menu_action(
&mut self,
item: super::types::TabContextMenuItem,
buffer_id: BufferId,
leaf_id: LeafId,
) -> AnyhowResult<()> {
use super::types::TabContextMenuItem;
match item {
TabContextMenuItem::Close => {
self.close_tab_in_split(buffer_id, leaf_id);
}
TabContextMenuItem::CloseOthers => {
self.close_other_tabs_in_split(buffer_id, leaf_id);
}
TabContextMenuItem::CloseToRight => {
self.close_tabs_to_right_in_split(buffer_id, leaf_id);
}
TabContextMenuItem::CloseToLeft => {
self.close_tabs_to_left_in_split(buffer_id, leaf_id);
}
TabContextMenuItem::CloseAll => {
self.close_all_tabs_in_split(leaf_id);
}
TabContextMenuItem::CopyRelativePath => {
self.copy_buffer_path(buffer_id, true);
}
TabContextMenuItem::CopyFullPath => {
self.copy_buffer_path(buffer_id, false);
}
}
Ok(())
}
pub(super) fn handle_file_explorer_context_menu_key(
&mut self,
code: crossterm::event::KeyCode,
modifiers: crossterm::event::KeyModifiers,
) -> Option<AnyhowResult<()>> {
use crossterm::event::KeyCode;
use crossterm::event::KeyModifiers;
if modifiers != KeyModifiers::NONE {
return None;
}
match code {
KeyCode::Up => {
if let Some(ref mut menu) = self.active_window_mut().file_explorer_context_menu {
menu.prev_item();
}
Some(Ok(()))
}
KeyCode::Down => {
if let Some(ref mut menu) = self.active_window_mut().file_explorer_context_menu {
menu.next_item();
}
Some(Ok(()))
}
KeyCode::Enter => {
let item = {
let menu = self
.active_window_mut()
.file_explorer_context_menu
.as_ref()?;
menu.items()[menu.highlighted]
};
self.active_window_mut().file_explorer_context_menu = None;
self.execute_file_explorer_context_menu_action(item);
Some(Ok(()))
}
KeyCode::Esc => {
self.active_window_mut().file_explorer_context_menu = None;
Some(Ok(()))
}
_ => None,
}
}
pub(super) fn handle_file_explorer_context_menu_click(
&mut self,
col: u16,
row: u16,
) -> Option<AnyhowResult<()>> {
let frame_w = self.active_chrome().last_frame_width;
let frame_h = self.active_chrome().last_frame_height;
let clicked_item: Option<super::types::FileExplorerContextMenuItem> = {
let menu = self.active_window().file_explorer_context_menu.as_ref()?;
let (menu_x, menu_y) = menu.clamped_position(frame_w, frame_h);
let menu_width = super::types::FILE_EXPLORER_CONTEXT_MENU_WIDTH;
let menu_height = menu.height();
if col < menu_x
|| col >= menu_x + menu_width
|| row < menu_y
|| row >= menu_y + menu_height
{
self.active_window_mut().file_explorer_context_menu = None;
return Some(Ok(()));
}
if row == menu_y || row == menu_y + menu_height - 1 {
return Some(Ok(()));
}
let item_idx = (row - menu_y - 1) as usize;
menu.items().get(item_idx).copied()
};
self.active_window_mut().file_explorer_context_menu = None;
if let Some(item) = clicked_item {
self.execute_file_explorer_context_menu_action(item);
}
Some(Ok(()))
}
fn execute_file_explorer_context_menu_action(
&mut self,
item: super::types::FileExplorerContextMenuItem,
) {
use super::types::FileExplorerContextMenuItem;
match item {
FileExplorerContextMenuItem::NewFile => self.file_explorer_new_file(),
FileExplorerContextMenuItem::NewDirectory => self.file_explorer_new_directory(),
FileExplorerContextMenuItem::Rename => self.file_explorer_rename(),
FileExplorerContextMenuItem::Cut => self.active_window_mut().file_explorer_cut(),
FileExplorerContextMenuItem::Copy => self.active_window_mut().file_explorer_copy(),
FileExplorerContextMenuItem::Paste => self.file_explorer_paste(),
FileExplorerContextMenuItem::Duplicate => self.file_explorer_duplicate(),
FileExplorerContextMenuItem::Delete => self.file_explorer_delete(),
FileExplorerContextMenuItem::CopyFullPath => self.file_explorer_copy_path(false),
FileExplorerContextMenuItem::CopyRelativePath => self.file_explorer_copy_path(true),
}
}
fn show_file_explorer_status_tooltip(&mut self, path: std::path::PathBuf, col: u16, row: u16) {
use crate::view::popup::{Popup, PopupPosition};
use ratatui::style::Style;
let is_directory = path.is_dir();
let decoration = self
.active_window()
.file_explorer_decoration_cache
.direct_for_path(&path)
.cloned();
let bubbled_decoration = if is_directory && decoration.is_none() {
self.active_window()
.file_explorer_decoration_cache
.bubbled_for_path(&path)
.cloned()
} else {
None
};
let has_unsaved_changes = if is_directory {
self.windows
.get(&self.active_window)
.map(|w| &w.buffers)
.expect("active window present")
.iter()
.any(|(buffer_id, state)| {
if state.buffer.is_modified() {
if let Some(metadata) = self.active_window().buffer_metadata.get(buffer_id)
{
if let Some(file_path) = metadata.file_path() {
return file_path.starts_with(&path);
}
}
}
false
})
} else {
self.windows
.get(&self.active_window)
.map(|w| &w.buffers)
.expect("active window present")
.iter()
.any(|(buffer_id, state)| {
if state.buffer.is_modified() {
if let Some(metadata) = self.active_window().buffer_metadata.get(buffer_id)
{
return metadata.file_path() == Some(&path);
}
}
false
})
};
let mut lines: Vec<String> = Vec::new();
if let Some(decoration) = &decoration {
let symbol = &decoration.symbol;
let explanation = match symbol.as_str() {
"U" => "Untracked - File is not tracked by git",
"M" => "Modified - File has unstaged changes",
"A" => "Added - File is staged for commit",
"D" => "Deleted - File is staged for deletion",
"R" => "Renamed - File has been renamed",
"C" => "Copied - File has been copied",
"!" => "Conflicted - File has merge conflicts",
"●" => "Has changes - Contains modified files",
_ => "Unknown status",
};
lines.push(format!("{} - {}", symbol, explanation));
} else if bubbled_decoration.is_some() {
lines.push("● - Contains modified files".to_string());
} else if has_unsaved_changes {
if is_directory {
lines.push("● - Contains unsaved changes".to_string());
} else {
lines.push("● - Unsaved changes in editor".to_string());
}
} else {
return; }
if is_directory {
if let Some(modified_files) = self.get_modified_files_in_directory(&path) {
lines.push(String::new()); lines.push("Modified files:".to_string());
let resolved_path = path.canonicalize().unwrap_or_else(|_| path.clone());
const MAX_FILES: usize = 8;
for (i, file) in modified_files.iter().take(MAX_FILES).enumerate() {
let display_name = file
.strip_prefix(&resolved_path)
.unwrap_or(file)
.to_string_lossy()
.to_string();
lines.push(format!(" {}", display_name));
if i == MAX_FILES - 1 && modified_files.len() > MAX_FILES {
lines.push(format!(
" ... and {} more",
modified_files.len() - MAX_FILES
));
break;
}
}
}
} else {
if let Some(stats) = self.get_git_diff_stats(&path) {
lines.push(String::new()); lines.push(stats);
}
}
if lines.is_empty() {
return;
}
let mut popup = Popup::text(lines, &*self.theme.read().unwrap());
popup.title = Some("Git Status".to_string());
popup.transient = true;
popup.position = PopupPosition::Fixed { x: col, y: row + 1 };
popup.width = 50;
popup.max_height = 15;
popup.border_style = Style::default().fg(self.theme.read().unwrap().popup_border_fg);
popup.background_style = Style::default().bg(self.theme.read().unwrap().popup_bg);
let __buffer_id = self.active_buffer();
if let Some(state) = self
.windows
.get_mut(&self.active_window)
.map(|w| &mut w.buffers)
.expect("active window present")
.get_mut(&__buffer_id)
{
state.popups.show(popup);
}
}
fn dismiss_file_explorer_status_tooltip(&mut self) {
let __buffer_id = self.active_buffer();
if let Some(state) = self
.windows
.get_mut(&self.active_window)
.map(|w| &mut w.buffers)
.expect("active window present")
.get_mut(&__buffer_id)
{
state.popups.dismiss_transient();
}
}
fn get_git_diff_stats(&self, path: &std::path::Path) -> Option<String> {
use crate::services::process_hidden::HideWindow;
use std::process::Command;
let output = Command::new("git")
.args(["diff", "--numstat", "--"])
.arg(path)
.current_dir(&self.working_dir)
.hide_window()
.output()
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
let line = stdout.lines().next()?;
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() >= 2 {
let insertions = parts[0];
let deletions = parts[1];
if insertions == "-" && deletions == "-" {
return Some("Binary file changed".to_string());
}
let ins: i32 = insertions.parse().unwrap_or(0);
let del: i32 = deletions.parse().unwrap_or(0);
if ins > 0 || del > 0 {
return Some(format!("+{} -{} lines", ins, del));
}
}
let staged_output = Command::new("git")
.args(["diff", "--numstat", "--cached", "--"])
.arg(path)
.current_dir(&self.working_dir)
.hide_window()
.output()
.ok()?;
if staged_output.status.success() {
let staged_stdout = String::from_utf8_lossy(&staged_output.stdout);
if let Some(line) = staged_stdout.lines().next() {
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() >= 2 {
let insertions = parts[0];
let deletions = parts[1];
if insertions == "-" && deletions == "-" {
return Some("Binary file staged".to_string());
}
let ins: i32 = insertions.parse().unwrap_or(0);
let del: i32 = deletions.parse().unwrap_or(0);
if ins > 0 || del > 0 {
return Some(format!("+{} -{} lines (staged)", ins, del));
}
}
}
}
None
}
fn get_modified_files_in_directory(
&self,
dir_path: &std::path::Path,
) -> Option<Vec<std::path::PathBuf>> {
use crate::services::process_hidden::HideWindow;
use std::process::Command;
let resolved_path = dir_path
.canonicalize()
.unwrap_or_else(|_| dir_path.to_path_buf());
let output = Command::new("git")
.args(["status", "--porcelain", "--"])
.arg(&resolved_path)
.current_dir(&self.working_dir)
.hide_window()
.output()
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
let modified_files: Vec<std::path::PathBuf> = stdout
.lines()
.filter_map(|line| {
if line.len() > 3 {
let file_part = &line[3..];
let file_name = if file_part.contains(" -> ") {
file_part.split(" -> ").last().unwrap_or(file_part)
} else {
file_part
};
Some(self.working_dir.join(file_name))
} else {
None
}
})
.collect();
if modified_files.is_empty() {
None
} else {
Some(modified_files)
}
}
fn handle_floating_widget_panel_wheel(&mut self, col: u16, row: u16, delta: i32) -> bool {
let inner = match self.floating_widget_panel.as_ref() {
Some(fwp) => match fwp.last_inner_rect {
Some(rect) => rect,
None => return false,
},
None => return false,
};
if col < inner.x || col >= inner.x + inner.width {
return false;
}
if row < inner.y || row >= inner.y + inner.height {
return false;
}
self.handle_widget_panel_wheel(super::FLOATING_PANEL_BUFFER_ID, delta)
}
fn handle_floating_widget_click(&mut self, col: u16, row: u16) {
let (panel_id, inner) = match self.floating_widget_panel.as_ref() {
Some(fwp) => match fwp.last_inner_rect {
Some(rect) => (fwp.panel_id, rect),
None => return,
},
None => return,
};
if col < inner.x || col >= inner.x + inner.width {
return;
}
if row < inner.y || row >= inner.y + inner.height {
return;
}
let brow = (row - inner.y) as u32;
let entries = self
.floating_widget_panel
.as_ref()
.map(|f| f.entries.clone())
.unwrap_or_default();
let local_screen_col = (col - inner.x) as usize;
let bcol = match entries.get(brow as usize) {
Some(entry) => screen_col_to_byte(&entry.text, local_screen_col),
None => return,
};
let (hit_payload, hit_event, hit_key, hit_kind) =
match self
.widget_registry
.hit_test(super::FLOATING_PANEL_BUFFER_ID, brow, bcol as u32)
{
Some((_, hit)) => (
hit.payload.clone(),
hit.event_type.to_string(),
hit.widget_key.clone(),
hit.widget_kind,
),
None => return,
};
if !hit_key.is_empty() {
let tabbable = self
.widget_registry
.get(panel_id)
.map(|p| p.tabbable.iter().any(|k| k == &hit_key))
.unwrap_or(false);
if tabbable {
self.set_panel_focus_and_notify(panel_id, hit_key.clone());
}
self.rerender_widget_panel(panel_id);
}
let handled_specially = if hit_kind == "tree" && hit_event == "expand" {
if let Some(item_key) = hit_payload.get("key").and_then(|v| v.as_str()) {
self.handle_widget_tree_expand_toggle(panel_id, &hit_key, item_key);
true
} else {
false
}
} else {
false
};
if !handled_specially
&& self
.plugin_manager
.read()
.unwrap()
.has_hook_handlers("widget_event")
{
self.plugin_manager.read().unwrap().run_hook(
"widget_event",
crate::services::plugins::hooks::HookArgs::WidgetEvent {
panel_id,
widget_key: hit_key,
event_type: hit_event,
payload: hit_payload,
},
);
}
}
}
fn screen_col_to_byte(text: &str, target_col: usize) -> usize {
use unicode_width::UnicodeWidthChar;
let mut byte = 0;
let mut col = 0usize;
for ch in text.chars() {
let w = UnicodeWidthChar::width(ch).unwrap_or(0);
if col + w > target_col {
return byte;
}
col += w;
byte += ch.len_utf8();
}
byte
}