use bytes::Bytes; use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; use ratatui::layout::{Direction, Rect}; use tracing::warn; use crate::{ app::state::{ AgentPanelSort, AppState, ContextMenuKind, ContextMenuState, DragState, DragTarget, MenuListState, Mode, RightClickPassthroughGesture, TabPressState, ViewLayout, WorkspacePressState, }, layout::{PaneInfo, SplitBorder}, selection::Selection, terminal::TerminalRuntimeRegistry, }; #[cfg(test)] use super::WheelRouting; use super::{ modal::{ apply_global_menu_action, confirm_close_cancel, global_menu_actions, leave_modal, modal_action_from_buttons, open_global_menu, open_new_tab_dialog, ModalAction, }, settings::SettingsAction, ScrollbarClickTarget, TAB_DRAG_THRESHOLD, WORKSPACE_DRAG_THRESHOLD, }; pub(super) enum MouseAction { Settings(SettingsAction), FocusWorkspace { ws_idx: usize, }, FocusTab { tab_idx: usize, }, FocusPane { ws_idx: usize, pane_id: crate::layout::PaneId, }, FocusToastTarget, MoveWorkspace { source_ws_idx: usize, insert_idx: usize, }, MoveTab { ws_idx: usize, source_tab_idx: usize, insert_idx: usize, }, SetSplitRatio { path: Vec, ratio: f32, }, RenameModal(ModalAction), ConfirmCloseAccept, ContextMenu { menu: ContextMenuState, idx: usize, }, } enum MobileMouseResult { Ignored, Consumed, Action(MouseAction), } impl AppState { pub(crate) fn handle_pane_mouse_only( &mut self, terminal_runtimes: &TerminalRuntimeRegistry, mouse: MouseEvent, ) { if self.mode != Mode::Terminal { return; } let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() else { return; }; match mouse.kind { MouseEventKind::ScrollUp | MouseEventKind::ScrollDown | MouseEventKind::ScrollLeft | MouseEventKind::ScrollRight => { self.forward_pane_reported_wheel(terminal_runtimes, &info, mouse); } MouseEventKind::Down(_) | MouseEventKind::Up(_) | MouseEventKind::Drag(_) => { self.forward_pane_mouse_button(terminal_runtimes, &info, mouse); } MouseEventKind::Moved => { self.forward_pane_mouse_motion(terminal_runtimes, &info, mouse); } } } pub(super) fn handle_mouse( &mut self, terminal_runtimes: &mut TerminalRuntimeRegistry, mouse: MouseEvent, ) -> Option { if self.mode == Mode::Onboarding { self.handle_onboarding_mouse(mouse); return None; } if self.mode == Mode::Terminal && self.clickable_toast_at(mouse.column, mouse.row) && matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { return Some(MouseAction::FocusToastTarget); } if self.mode == Mode::Terminal && self.clickable_toast_at(mouse.column, mouse.row) && matches!(mouse.kind, MouseEventKind::Up(MouseButton::Left)) { return None; } if self.mode == Mode::Settings { return self.handle_settings_mouse(mouse).map(MouseAction::Settings); } let launcher_enabled = self.view.layout != ViewLayout::Mobile && !self.sidebar_collapsed && matches!( self.mode, Mode::Terminal | Mode::Navigate | Mode::Resize | Mode::GlobalMenu | Mode::KeybindHelp ); let launcher = self.global_launcher_rect(); let launcher_hit = launcher_enabled && mouse.column >= launcher.x && mouse.column < launcher.x + launcher.width && mouse.row >= launcher.y && mouse.row < launcher.y + launcher.height; if matches!(mouse.kind, MouseEventKind::Moved) && self.mode == Mode::GlobalMenu { let actions = global_menu_actions(self); let hovered = self .global_menu_item_at(mouse.column, mouse.row) .and_then(|action| actions.iter().position(|item| *item == action)); self.global_menu.hover(hovered); return None; } if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) && launcher_hit { if self.mode == Mode::GlobalMenu { leave_modal(self); } else { open_global_menu(self); } return None; } if self.mode == Mode::GlobalMenu { if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { if let Some(action) = self.global_menu_item_at(mouse.column, mouse.row) { apply_global_menu_action(self, action); } else { leave_modal(self); } } return None; } if self.mode == Mode::KeybindHelp { return None; } if self.view.layout == ViewLayout::Mobile { match self.handle_mobile_mouse(mouse) { MobileMouseResult::Ignored => {} MobileMouseResult::Consumed => return None, MobileMouseResult::Action(action) => return Some(action), } } let sidebar = self.view.sidebar_rect; let in_sidebar = mouse.column >= sidebar.x && mouse.column < sidebar.x + sidebar.width && mouse.row >= sidebar.y && mouse.row < sidebar.y + sidebar.height; if self.handle_right_click_passthrough(terminal_runtimes, mouse, in_sidebar) { return None; } if self.mode == Mode::OpenExistingWorktree { match mouse.kind { MouseEventKind::ScrollUp => { if let Some(open) = &mut self.worktree_open { open.select_previous_filtered(); } return None; } MouseEventKind::ScrollDown => { if let Some(open) = &mut self.worktree_open { open.select_next_filtered(); } return None; } _ => {} } } if matches!( self.mode, Mode::NewLinkedWorktree | Mode::OpenExistingWorktree | Mode::ConfirmRemoveWorktree ) && !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { return None; } match mouse.kind { MouseEventKind::Down(MouseButton::Left) => { self.selection = None; self.selection_autoscroll = None; self.workspace_press = None; if self.mode == Mode::ConfirmClose { let popup = self.confirm_close_rect(); let inner = Rect::new( popup.x + 1, popup.y + 1, popup.width.saturating_sub(2), popup.height.saturating_sub(2), ); let (confirm, cancel) = crate::ui::confirm_close_button_rects(inner); match modal_action_from_buttons( mouse.column, mouse.row, &[ (confirm, ModalAction::Confirm), (cancel, ModalAction::Cancel), ], ) { Some(ModalAction::Confirm) => { return Some(MouseAction::ConfirmCloseAccept); } Some(ModalAction::Cancel) | None => confirm_close_cancel(self), _ => {} } return None; } if self.mode == Mode::NewLinkedWorktree { if let Some(inner) = crate::ui::new_linked_worktree_inner_rect(self.screen_rect()) { let (create, cancel) = crate::ui::new_linked_worktree_button_rects(inner); match modal_action_from_buttons( mouse.column, mouse.row, &[ (create, ModalAction::Confirm), (cancel, ModalAction::Cancel), ], ) { Some(ModalAction::Confirm) => { self.request_submit_worktree_create = true; } Some(ModalAction::Cancel) if !self .worktree_create .as_ref() .is_some_and(|create| create.creating) => { self.worktree_create = None; self.name_input.clear(); self.name_input_replace_on_type = false; leave_modal(self); } _ => {} } } return None; } if self.mode == Mode::OpenExistingWorktree { if let Some(open) = self.worktree_open.as_ref() { if let Some(inner) = crate::ui::open_existing_worktree_inner_rect( self.screen_rect(), open.entries.len(), ) { let filtered = open.filtered_indices(); let max_rows = crate::ui::open_existing_worktree_max_visible_rows(inner); let start = crate::ui::open_existing_worktree_visible_start(open, max_rows); if mouse.row == inner.y.saturating_add(1) && mouse.column >= inner.x && mouse.column < inner.x.saturating_add(inner.width) { if let Some(open) = &mut self.worktree_open { open.search_focused = true; } return None; } let row_idx = if rect_contains(inner, mouse.column, mouse.row) { mouse .row .checked_sub(inner.y.saturating_add(3)) .map(usize::from) .map(|row| row / 2) .filter(|row| *row < max_rows) .and_then(|row| filtered.get(start + row).copied()) } else { None }; if let Some(entry_idx) = row_idx { if let Some(open) = &mut self.worktree_open { open.selected = entry_idx; } self.request_submit_worktree_open = true; return None; } let (open_button, cancel) = crate::ui::open_existing_worktree_button_rects(inner); match modal_action_from_buttons( mouse.column, mouse.row, &[ (open_button, ModalAction::Confirm), (cancel, ModalAction::Cancel), ], ) { Some(ModalAction::Confirm) => { self.request_submit_worktree_open = true; } Some(ModalAction::Cancel) => { self.worktree_open = None; leave_modal(self); } _ => {} } } } return None; } if self.mode == Mode::ConfirmRemoveWorktree { if let Some(popup) = crate::ui::remove_worktree_popup_rect(self.screen_rect()) { let inner = Rect::new( popup.x + 1, popup.y + 1, popup.width.saturating_sub(2), popup.height.saturating_sub(2), ); let force_confirmation = self .worktree_remove .as_ref() .is_some_and(|remove| remove.force_confirmation); let (remove, cancel) = crate::ui::remove_worktree_button_rects(inner, force_confirmation); match modal_action_from_buttons( mouse.column, mouse.row, &[ (remove, ModalAction::Confirm), (cancel, ModalAction::Cancel), ], ) { Some(ModalAction::Confirm) => { self.request_submit_worktree_remove = true; } Some(ModalAction::Cancel) if !self .worktree_remove .as_ref() .is_some_and(|remove| remove.removing) => { self.worktree_remove = None; leave_modal(self); } _ => {} } } return None; } if matches!( self.mode, Mode::RenameWorkspace | Mode::RenameTab | Mode::RenamePane ) { let action = self .rename_modal_inner() .map(crate::ui::rename_button_rects) .and_then(|(save, clear, cancel)| { modal_action_from_buttons( mouse.column, mouse.row, &[ (save, ModalAction::Save), (clear, ModalAction::Clear), (cancel, ModalAction::Cancel), ], ) }) .unwrap_or(ModalAction::Cancel); return Some(MouseAction::RenameModal(action)); } if self.mode == Mode::ContextMenu { let item_idx = self.context_menu_item_at(mouse.column, mouse.row); if let Some(menu) = self.context_menu.take() { if let Some(idx) = item_idx { return Some(MouseAction::ContextMenu { menu, idx }); } else { leave_modal(self); } } return None; } if self.on_sidebar_divider(mouse.column, mouse.row) { self.drag = Some(DragState { target: DragTarget::SidebarDivider, }); self.set_manual_sidebar_width(mouse.column); return None; } if self.on_sidebar_section_divider(mouse.column, mouse.row) { self.drag = Some(DragState { target: DragTarget::SidebarSectionDivider, }); self.set_sidebar_section_split(mouse.row); return None; } if !in_sidebar { if let Some(border) = self.find_border_at(mouse.column, mouse.row) { let grab_offset = match border.direction { Direction::Horizontal => border.pos.saturating_sub(mouse.column), Direction::Vertical => border.pos.saturating_sub(mouse.row), }; self.drag = Some(DragState { target: DragTarget::PaneSplit { path: border.path.clone(), direction: border.direction, area: border.area, grab_offset, }, }); return None; } if let Some((pane_id, target)) = self.scrollbar_target_at(terminal_runtimes, mouse.column, mouse.row) { self.focus_pane(pane_id); match target { ScrollbarClickTarget::Thumb { grab_row_offset } => { self.drag = Some(DragState { target: DragTarget::PaneScrollbar { pane_id, grab_row_offset, }, }); } ScrollbarClickTarget::Track { offset_from_bottom } => { self.set_pane_scroll_offset( terminal_runtimes, pane_id, offset_from_bottom, ); } } if self.mode != Mode::Terminal { self.mode = Mode::Terminal; } return None; } } if self.on_tab_scroll_left_button(mouse.column, mouse.row) { self.scroll_tabs_left(); return None; } if self.on_tab_scroll_right_button(mouse.column, mouse.row) { self.scroll_tabs_right(); return None; } if let (Some(ws_idx), Some(tab_idx)) = (self.active, self.tab_at(mouse.column, mouse.row)) { self.tab_press = Some(TabPressState { ws_idx, tab_idx, start_col: mouse.column, start_row: mouse.row, }); return None; } if self.on_new_tab_button(mouse.column, mouse.row) { if self.prompt_new_tab_name { open_new_tab_dialog(self); } else { self.request_new_tab = true; self.mode = Mode::Terminal; } return None; } if in_sidebar { if self.on_sidebar_toggle(mouse.column, mouse.row) { self.sidebar_collapsed = !self.sidebar_collapsed; return None; } if self.sidebar_collapsed { if let Some(idx) = self.collapsed_workspace_at_row(mouse.row) { self.mode = Mode::Terminal; return Some(MouseAction::FocusWorkspace { ws_idx: idx }); } if let Some((ws_idx, _tab_idx, pane_id)) = self.collapsed_agent_detail_target_at(mouse.row) { self.mode = Mode::Terminal; return Some(MouseAction::FocusPane { ws_idx, pane_id }); } return None; } let new_button = self.sidebar_new_button_rect(); let on_new_button = mouse.row >= new_button.y && mouse.row < new_button.y + new_button.height && mouse.column >= new_button.x && mouse.column < new_button.x + new_button.width; if on_new_button { self.request_new_workspace = true; return None; } if let Some(target) = self.workspace_list_scrollbar_target_at(mouse.column, mouse.row) { match target { ScrollbarClickTarget::Thumb { grab_row_offset } => { self.drag = Some(DragState { target: DragTarget::WorkspaceListScrollbar { grab_row_offset }, }); } ScrollbarClickTarget::Track { offset_from_bottom } => { self.set_workspace_list_offset_from_bottom(offset_from_bottom); } } return None; } let cards = if self.view.workspace_card_areas.is_empty() { crate::ui::compute_workspace_card_areas(self, self.view.sidebar_rect) } else { self.view.workspace_card_areas.clone() }; if let Some(card) = cards.iter().find(|card| { mouse.row == card.rect.y && mouse.column == card.rect.x && mouse.column < card.rect.x + card.rect.width }) { if let Some((key, collapsed)) = crate::ui::workspace_parent_group_state(self, card.ws_idx) { if collapsed { self.collapsed_space_keys.remove(&key); } else { self.collapsed_space_keys.insert(key); } self.mark_session_dirty(); return None; } } if let Some(idx) = self.workspace_at_row(mouse.row) { self.workspace_press = Some(WorkspacePressState { ws_idx: idx, start_col: mouse.column, start_row: mouse.row, }); return None; } if self.on_agent_panel_sort_toggle(mouse.column, mouse.row) { self.agent_panel_sort = match self.agent_panel_sort { AgentPanelSort::Spaces => AgentPanelSort::Priority, AgentPanelSort::Priority => AgentPanelSort::Spaces, }; self.agent_panel_scroll = 0; self.mark_session_dirty(); return None; } if let Some(target) = self.agent_panel_scrollbar_target_at(mouse.column, mouse.row) { match target { ScrollbarClickTarget::Thumb { grab_row_offset } => { self.drag = Some(DragState { target: DragTarget::AgentPanelScrollbar { grab_row_offset }, }); } ScrollbarClickTarget::Track { offset_from_bottom } => { self.set_agent_panel_offset_from_bottom(offset_from_bottom); } } return None; } if let Some((ws_idx, _tab_idx, pane_id)) = self.agent_detail_target_at(mouse.row) { self.mode = Mode::Terminal; return Some(MouseAction::FocusPane { ws_idx, pane_id }); } } else if let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() { if self.mode != Mode::Terminal { self.mode = Mode::Terminal; } if self.forward_pane_mouse_button(terminal_runtimes, &info, mouse) { self.selection = None; self.selection_autoscroll = None; if let Some(ws_idx) = self.active { return Some(MouseAction::FocusPane { ws_idx, pane_id: info.id, }); } return None; } let (row, col) = ( mouse.row - info.inner_rect.y, mouse.column - info.inner_rect.x, ); self.selection = Some(Selection::anchor( info.id, row, col, self.pane_scroll_metrics(terminal_runtimes, info.id), )); if let Some(ws_idx) = self.active { return Some(MouseAction::FocusPane { ws_idx, pane_id: info.id, }); } } else if let Some(info) = self.view.pane_infos.iter().find(|p| { mouse.column >= p.rect.x && mouse.column < p.rect.x + p.rect.width && mouse.row >= p.rect.y && mouse.row < p.rect.y + p.rect.height }) { let id = info.id; if self.mode != Mode::Terminal { self.mode = Mode::Terminal; } if let Some(ws_idx) = self.active { return Some(MouseAction::FocusPane { ws_idx, pane_id: id, }); } } } MouseEventKind::Drag(MouseButton::Left) => { if self.selection.is_some() { self.update_selection_drag(terminal_runtimes, mouse.column, mouse.row); return None; } if self.drag.is_none() { if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() { if self.forward_pane_mouse_button(terminal_runtimes, &info, mouse) { self.selection = None; self.selection_autoscroll = None; return None; } } } let workspace_drop_index = self.workspace_drop_index_at_row(mouse.row); let tab_drop_index = self.tab_drop_index_at(mouse.column, mouse.row); if self.drag.is_none() { if let Some(press) = &self.workspace_press { let delta_col = mouse.column.abs_diff(press.start_col); let delta_row = mouse.row.abs_diff(press.start_row); let can_reorder = self .workspaces .get(press.ws_idx) .is_some_and(|ws| ws.worktree_space().is_none()); if can_reorder && delta_col.max(delta_row) >= WORKSPACE_DRAG_THRESHOLD { self.drag = Some(DragState { target: DragTarget::WorkspaceReorder { source_ws_idx: press.ws_idx, insert_idx: workspace_drop_index, }, }); } } else if let Some(press) = &self.tab_press { let delta_col = mouse.column.abs_diff(press.start_col); let delta_row = mouse.row.abs_diff(press.start_row); if delta_col.max(delta_row) >= TAB_DRAG_THRESHOLD { self.drag = Some(DragState { target: DragTarget::TabReorder { ws_idx: press.ws_idx, source_tab_idx: press.tab_idx, insert_idx: tab_drop_index, }, }); } } } if let Some(DragState { target: DragTarget::WorkspaceReorder { insert_idx, .. }, }) = &mut self.drag { *insert_idx = workspace_drop_index; } else if let Some(DragState { target: DragTarget::TabReorder { ws_idx, insert_idx, .. }, }) = &mut self.drag { if self.active == Some(*ws_idx) { *insert_idx = tab_drop_index; } } else if let Some(drag) = &self.drag { match &drag.target { DragTarget::WorkspaceReorder { .. } | DragTarget::TabReorder { .. } => {} DragTarget::WorkspaceListScrollbar { grab_row_offset } => { if let Some(offset_from_bottom) = self.workspace_list_offset_for_drag_row(mouse.row, *grab_row_offset) { self.set_workspace_list_offset_from_bottom(offset_from_bottom); } } DragTarget::AgentPanelScrollbar { grab_row_offset } => { if let Some(offset_from_bottom) = self.agent_panel_offset_for_drag_row(mouse.row, *grab_row_offset) { self.set_agent_panel_offset_from_bottom(offset_from_bottom); } } DragTarget::PaneSplit { path, direction, area, grab_offset, } => { let ratio = match direction { Direction::Horizontal => { (mouse .column .saturating_add(*grab_offset) .saturating_sub(area.x)) as f32 / area.width.max(1) as f32 } Direction::Vertical => { (mouse .row .saturating_add(*grab_offset) .saturating_sub(area.y)) as f32 / area.height.max(1) as f32 } }; let ratio = ratio.clamp(0.1, 0.9); let path = path.clone(); return Some(MouseAction::SetSplitRatio { path, ratio }); } DragTarget::PaneScrollbar { pane_id, grab_row_offset, } => { if let Some(offset_from_bottom) = self.scrollbar_offset_for_pane_row( terminal_runtimes, *pane_id, mouse.row, *grab_row_offset, ) { self.set_pane_scroll_offset( terminal_runtimes, *pane_id, offset_from_bottom, ); } } DragTarget::SidebarDivider => { self.set_manual_sidebar_width(mouse.column); } DragTarget::SidebarSectionDivider => { self.set_sidebar_section_split(mouse.row); } DragTarget::ReleaseNotesScrollbar { .. } | DragTarget::ProductAnnouncementScrollbar { .. } | DragTarget::KeybindHelpScrollbar { .. } => {} } } } MouseEventKind::Up(MouseButton::Left) => { // Mouse-up either finishes a drag selection or releases after a // double-click copy; the latter is already copied. if let Some(selection) = self.selection.as_ref() { let was_click = selection.was_just_click(); let was_already_copied = selection.is_done(); self.workspace_press = None; self.tab_press = None; self.drag = None; self.selection_autoscroll = None; if was_click { self.selection = None; } else if !was_already_copied { self.copy_selection(terminal_runtimes); } return None; } if self.drag.is_none() { if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() { if self.forward_pane_mouse_button(terminal_runtimes, &info, mouse) { self.selection = None; self.selection_autoscroll = None; self.workspace_press = None; self.tab_press = None; self.drag = None; return None; } } } let workspace_press = self.workspace_press.take(); let tab_press = self.tab_press.take(); match self.drag.take() { Some(DragState { target: DragTarget::WorkspaceReorder { source_ws_idx, insert_idx: Some(insert_idx), }, }) => { return Some(MouseAction::MoveWorkspace { source_ws_idx, insert_idx, }); } Some(DragState { target: DragTarget::TabReorder { ws_idx, source_tab_idx, insert_idx: Some(insert_idx), }, }) => { if self.active == Some(ws_idx) { self.mode = Mode::Terminal; return Some(MouseAction::MoveTab { ws_idx, source_tab_idx, insert_idx, }); } } Some(_) => {} None => { if let Some(press) = workspace_press { self.mode = Mode::Terminal; return Some(MouseAction::FocusWorkspace { ws_idx: press.ws_idx, }); } if let Some(press) = tab_press { if self.active == Some(press.ws_idx) { self.mode = Mode::Terminal; return Some(MouseAction::FocusTab { tab_idx: press.tab_idx, }); } } } } } MouseEventKind::Up(MouseButton::Middle) | MouseEventKind::Drag(MouseButton::Middle) if !in_sidebar => { if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() { let _ = self.forward_pane_mouse_button(terminal_runtimes, &info, mouse); } } MouseEventKind::ScrollUp | MouseEventKind::ScrollDown if self.on_tab_bar(mouse.column, mouse.row) => { match mouse.kind { MouseEventKind::ScrollUp => { if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) { if !ws.tabs.is_empty() { let prev = if ws.active_tab == 0 { ws.tabs.len() - 1 } else { ws.active_tab - 1 }; return Some(MouseAction::FocusTab { tab_idx: prev }); } } } MouseEventKind::ScrollDown => { if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) { if !ws.tabs.is_empty() { let next = (ws.active_tab + 1) % ws.tabs.len(); return Some(MouseAction::FocusTab { tab_idx: next }); } } } _ => {} } } MouseEventKind::ScrollUp | MouseEventKind::ScrollDown if !in_sidebar && self.scroll_selection_with_wheel(terminal_runtimes, mouse) => {} MouseEventKind::ScrollUp | MouseEventKind::ScrollDown if !in_sidebar => { self.selection = None; self.selection_autoscroll = None; self.handle_terminal_wheel(terminal_runtimes, mouse); } MouseEventKind::ScrollUp if in_sidebar => { let agent_area = self.agent_panel_rect(); let over_agent_panel = agent_area != Rect::default() && mouse.row >= agent_area.y && mouse.row < agent_area.y + agent_area.height; if over_agent_panel { if crate::ui::should_show_scrollbar(crate::ui::agent_panel_scroll_metrics( self, agent_area, )) { self.scroll_agent_panel(-1); } } else if crate::ui::should_show_scrollbar( crate::ui::workspace_list_scroll_metrics(self, self.workspace_list_rect()), ) { self.scroll_workspace_list(-1); } else { self.move_selected_workspace_by_visible_delta(-1); } } MouseEventKind::ScrollDown if in_sidebar => { let agent_area = self.agent_panel_rect(); let over_agent_panel = agent_area != Rect::default() && mouse.row >= agent_area.y && mouse.row < agent_area.y + agent_area.height; if over_agent_panel { if crate::ui::should_show_scrollbar(crate::ui::agent_panel_scroll_metrics( self, agent_area, )) { self.scroll_agent_panel(1); } } else if crate::ui::should_show_scrollbar( crate::ui::workspace_list_scroll_metrics(self, self.workspace_list_rect()), ) { self.scroll_workspace_list(1); } else { self.move_selected_workspace_by_visible_delta(1); } } MouseEventKind::Moved if self.mode == Mode::ContextMenu => { let hovered = self.context_menu_item_at(mouse.column, mouse.row); if let Some(menu) = &mut self.context_menu { menu.list.hover(hovered); } } MouseEventKind::Moved if self.mode == Mode::Terminal && !in_sidebar => { if let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() { let _ = self.forward_pane_mouse_motion(terminal_runtimes, &info, mouse); } } MouseEventKind::Down(MouseButton::Right) if in_sidebar && !self.sidebar_collapsed => { self.workspace_press = None; self.tab_press = None; if self .workspace_list_scrollbar_target_at(mouse.column, mouse.row) .is_some() { return None; } if let Some(idx) = self.workspace_at_row(mouse.row) { self.selected = idx; let kind = self .workspaces .get(idx) .and_then(|ws| { let group_state = crate::ui::workspace_parent_group_state(self, idx); let git_space = ws.git_space().cloned().or_else(|| { ws.resolved_identity_cwd_from(&self.terminals, terminal_runtimes) .as_deref() .and_then(crate::workspace::git_space_metadata) }); let is_linked_worktree = ws.worktree_space().map_or_else( || { git_space .as_ref() .is_some_and(|space| space.is_linked_worktree) }, |space| space.is_linked_worktree, ); let show_git_menu = ws.worktree_space().is_some() || git_space .as_ref() .is_some_and(|space| !space.is_linked_worktree); show_git_menu.then_some(ContextMenuKind::GitWorkspace { ws_idx: idx, is_linked_worktree, has_worktree_children: group_state.is_some(), collapsed: group_state .as_ref() .is_some_and(|(_, collapsed)| *collapsed), }) }) .unwrap_or(ContextMenuKind::Workspace { ws_idx: idx }); self.context_menu = Some(ContextMenuState { kind, x: mouse.column, y: mouse.row, list: MenuListState::new(0), }); self.mode = Mode::ContextMenu; } } MouseEventKind::Down(MouseButton::Right) if self.tab_at(mouse.column, mouse.row).is_some() => { if let (Some(ws_idx), Some(tab_idx)) = (self.active, self.tab_at(mouse.column, mouse.row)) { self.context_menu = Some(ContextMenuState { kind: ContextMenuKind::Tab { ws_idx, tab_idx }, x: mouse.column, y: mouse.row, list: MenuListState::new(0), }); self.mode = Mode::ContextMenu; } } MouseEventKind::Down(MouseButton::Right) if !in_sidebar => { if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() { let ws_idx = self.active?; let tab_idx = self .workspaces .get(ws_idx) .map(|ws| ws.active_tab_index())?; let previous_focused_pane_id = self .workspaces .get(ws_idx) .and_then(|ws| ws.focused_pane_id()); let source_pane_id = previous_focused_pane_id.filter(|pane_id| *pane_id != info.id); let has_manual_label = self .workspaces .get(ws_idx) .and_then(|ws| ws.pane_state(info.id)) .and_then(|pane| self.terminals.get(&pane.attached_terminal_id)) .and_then(|terminal| terminal.manual_label.as_ref()) .is_some(); self.context_menu = Some(ContextMenuState { kind: ContextMenuKind::Pane { ws_idx, tab_idx, pane_id: info.id, source_pane_id, has_manual_label, }, x: mouse.column, y: mouse.row, list: MenuListState::new(0), }); self.mode = Mode::ContextMenu; } } _ => {} } None } fn handle_mobile_mouse(&mut self, mouse: MouseEvent) -> MobileMouseResult { if self.mode == Mode::Navigate { match mouse.kind { MouseEventKind::ScrollUp => { self.scroll_mobile_switcher_at(mouse.column, mouse.row, -1); return MobileMouseResult::Consumed; } MouseEventKind::ScrollDown => { self.scroll_mobile_switcher_at(mouse.column, mouse.row, 1); return MobileMouseResult::Consumed; } MouseEventKind::Down(MouseButton::Left) => {} _ => return MobileMouseResult::Consumed, } } else if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { return MobileMouseResult::Ignored; } if self.mode != Mode::Navigate { if !matches!(self.mode, Mode::Terminal | Mode::Resize) { return MobileMouseResult::Ignored; } if rect_contains(self.view.mobile_menu_hit_area, mouse.column, mouse.row) { self.mobile_switcher_scroll = 0; self.mode = Mode::Navigate; return MobileMouseResult::Consumed; } return MobileMouseResult::Ignored; } let areas = crate::ui::mobile_switcher_areas(self); if rect_contains(areas.close, mouse.column, mouse.row) { self.mode = Mode::Terminal; return MobileMouseResult::Consumed; } match crate::ui::mobile_switcher_target_at(self, mouse.column, mouse.row) { Some(crate::ui::MobileSwitcherTarget::NewWorkspace) => { self.request_new_workspace = true; } Some(crate::ui::MobileSwitcherTarget::Workspace(ws_idx)) => { self.mode = Mode::Terminal; return MobileMouseResult::Action(MouseAction::FocusWorkspace { ws_idx }); } Some(crate::ui::MobileSwitcherTarget::NewTab) => { if self.prompt_new_tab_name { open_new_tab_dialog(self); } else { self.request_new_tab = true; self.mode = Mode::Terminal; } } Some(crate::ui::MobileSwitcherTarget::Tab(tab_idx)) => { self.mode = Mode::Terminal; return MobileMouseResult::Action(MouseAction::FocusTab { tab_idx }); } Some(crate::ui::MobileSwitcherTarget::Agent { ws_idx, tab_idx: _, pane_id, }) => { self.mode = Mode::Terminal; return MobileMouseResult::Action(MouseAction::FocusPane { ws_idx, pane_id }); } Some(crate::ui::MobileSwitcherTarget::Menu(action_idx)) => { let actions = global_menu_actions(self); if let Some(action) = actions.get(action_idx).copied() { apply_global_menu_action(self, action); } } None => {} } MobileMouseResult::Consumed } fn scroll_mobile_switcher_at(&mut self, _col: u16, _row: u16, delta: i16) { let max_scroll = crate::ui::mobile_switcher_max_scroll(self); apply_scroll( &mut self.mobile_switcher_scroll, delta.saturating_mul(2), max_scroll, ); } pub(super) fn screen_rect(&self) -> Rect { let sidebar = self.view.sidebar_rect; let terminal = self.view.terminal_area; let x = sidebar.x.min(terminal.x); let y = sidebar.y.min(terminal.y); let right = (sidebar.x + sidebar.width).max(terminal.x + terminal.width); let bottom = (sidebar.y + sidebar.height).max(terminal.y + terminal.height); Rect::new(x, y, right.saturating_sub(x), bottom.saturating_sub(y)) } pub(crate) fn context_menu_rect(&self) -> Option { let menu = self.context_menu.as_ref()?; let screen = self.screen_rect(); let max_item_w = menu .items() .iter() .map(|item| item.len() as u16) .max() .unwrap_or(0); let menu_w = (max_item_w + 4).max(14).min(screen.width.max(1)); let menu_h = (menu.items().len() as u16 + 2).min(screen.height.max(1)); let x = menu.x.min(screen.x + screen.width.saturating_sub(menu_w)); let y = menu.y.min(screen.y + screen.height.saturating_sub(menu_h)); Some(Rect::new(x, y, menu_w, menu_h)) } pub(crate) fn confirm_close_rect(&self) -> Rect { crate::ui::confirm_close_popup_rect(self.view.terminal_area).unwrap_or_default() } fn context_menu_item_at(&self, col: u16, row: u16) -> Option { let menu_rect = self.context_menu_rect()?; let inner_x = menu_rect.x + 1; let inner_y = menu_rect.y + 1; let inner_w = menu_rect.width.saturating_sub(2); let inner_h = menu_rect.height.saturating_sub(2); let item_count = self .context_menu .as_ref() .map(|menu| menu.items().len() as u16) .unwrap_or(0); if col >= inner_x && col < inner_x + inner_w && row >= inner_y && row < inner_y + inner_h.min(item_count) { Some((row - inner_y) as usize) } else { None } } pub(super) fn tab_at(&self, col: u16, row: u16) -> Option { self.view .tab_hit_areas .iter() .enumerate() .find_map(|(idx, area)| { (area.width > 0 && row >= area.y && row < area.y + area.height && col >= area.x && col < area.x + area.width) .then_some(idx) }) } pub(super) fn on_tab_bar(&self, col: u16, row: u16) -> bool { let area = self.view.tab_bar_rect; area.width > 0 && row >= area.y && row < area.y + area.height && col >= area.x && col < area.x + area.width } pub(super) fn on_tab_scroll_left_button(&self, col: u16, row: u16) -> bool { let area = self.view.tab_scroll_left_hit_area; area.width > 0 && row >= area.y && row < area.y + area.height && col >= area.x && col < area.x + area.width } pub(super) fn on_tab_scroll_right_button(&self, col: u16, row: u16) -> bool { let area = self.view.tab_scroll_right_hit_area; area.width > 0 && row >= area.y && row < area.y + area.height && col >= area.x && col < area.x + area.width } pub(super) fn tab_drop_index_at(&self, col: u16, row: u16) -> Option { if !self.on_tab_bar(col, row) { return None; } let visible_tabs: Vec<_> = self .view .tab_hit_areas .iter() .enumerate() .filter(|(_, rect)| rect.width > 0) .collect(); let (first_idx, first_rect) = *visible_tabs.first()?; let (last_idx, last_rect) = *visible_tabs.last()?; if self.on_tab_scroll_left_button(col, row) { return Some(0); } if self.on_tab_scroll_right_button(col, row) { return self .active .and_then(|idx| self.workspaces.get(idx)) .map(|ws| ws.tabs.len()); } let left_edge = if first_idx == 0 { first_rect.x } else { self.view.tab_scroll_left_hit_area.x + self.view.tab_scroll_left_hit_area.width }; let right_edge = if self .active .and_then(|idx| self.workspaces.get(idx)) .is_some_and(|ws| last_idx + 1 >= ws.tabs.len()) { last_rect.x + last_rect.width } else { self.view.tab_scroll_right_hit_area.x.saturating_sub(1) }; if col <= left_edge { return Some(first_idx); } if col >= right_edge { return Some(last_idx + 1); } for (idx, rect) in visible_tabs { let midpoint = rect.x + rect.width / 2; if col < midpoint { return Some(idx); } if col < rect.x + rect.width { return Some(idx + 1); } } Some(last_idx + 1) } pub(super) fn on_new_tab_button(&self, col: u16, row: u16) -> bool { let area = self.view.new_tab_hit_area; area.width > 0 && row >= area.y && row < area.y + area.height && col >= area.x && col < area.x + area.width } pub(super) fn find_border_at(&self, col: u16, row: u16) -> Option<&SplitBorder> { self.view.split_borders.iter().find(|b| match b.direction { Direction::Horizontal if self.pane_borders && !self.pane_gaps => { col == b.pos && row >= b.area.y && row < b.area.y + b.area.height } Direction::Horizontal if self.pane_borders && self.pane_gaps => { row >= b.area.y && row < b.area.y + b.area.height && col >= b.pos.saturating_sub(1) && col <= b.pos } Direction::Horizontal if !self.pane_borders && self.pane_gaps => { row >= b.area.y && row < b.area.y + b.area.height && b.pos.checked_sub(1).is_some_and(|gap_col| { col == gap_col && self.pane_frame_at(col, row).is_none() }) } Direction::Vertical if self.pane_borders && !self.pane_gaps => { row == b.pos && col >= b.area.x && col < b.area.x + b.area.width } Direction::Vertical if self.pane_borders && self.pane_gaps => { col >= b.area.x && col < b.area.x + b.area.width && row >= b.pos.saturating_sub(1) && row <= b.pos } Direction::Vertical if !self.pane_borders && self.pane_gaps => { col >= b.area.x && col < b.area.x + b.area.width && b.pos.checked_sub(1).is_some_and(|gap_row| { row == gap_row && self.pane_frame_at(col, row).is_none() }) } _ => false, }) } pub(super) fn pane_at(&self, col: u16, row: u16) -> Option<&PaneInfo> { self.view.pane_infos.iter().find(|p| { col >= p.inner_rect.x && col < p.inner_rect.x + p.inner_rect.width && row >= p.inner_rect.y && row < p.inner_rect.y + p.inner_rect.height }) } pub(super) fn pane_mouse_target(&self, col: u16, row: u16) -> Option<&PaneInfo> { self.pane_at(col, row) .or_else(|| self.pane_frame_at(col, row)) } pub(crate) fn pane_info_by_id(&self, pane_id: crate::layout::PaneId) -> Option<&PaneInfo> { self.view.pane_infos.iter().find(|info| info.id == pane_id) } pub(super) fn pane_frame_at(&self, col: u16, row: u16) -> Option<&PaneInfo> { self.view.pane_infos.iter().find(|p| { col >= p.rect.x && col < p.rect.x + p.rect.width && row >= p.rect.y && row < p.rect.y + p.rect.height }) } pub(super) fn focus_pane(&mut self, pane_id: crate::layout::PaneId) { let _ = pane_id; } fn clickable_toast_at(&self, col: u16, row: u16) -> bool { self.toast .as_ref() .is_some_and(|toast| toast.target.is_some()) && rect_contains(self.view.toast_hit_area, col, row) } pub(crate) fn focus_toast_target(&mut self) { let Some(target) = self.toast.as_ref().and_then(|toast| toast.target.clone()) else { return; }; let Some(ws_idx) = self .workspaces .iter() .position(|workspace| workspace.id == target.workspace_id) else { return; }; let Some(_tab_idx) = self.workspaces[ws_idx].find_tab_index_for_pane(target.pane_id) else { return; }; self.focus_pane_in_workspace(ws_idx, target.pane_id); self.toast = None; self.mode = Mode::Terminal; } pub(crate) fn scroll_pane_up( &self, terminal_runtimes: &TerminalRuntimeRegistry, pane_id: crate::layout::PaneId, lines: usize, ) { if let Some(ws_idx) = self.active { if let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id) { rt.scroll_up(lines); } } } pub(crate) fn scroll_pane_down( &self, terminal_runtimes: &TerminalRuntimeRegistry, pane_id: crate::layout::PaneId, lines: usize, ) { if let Some(ws_idx) = self.active { if let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id) { rt.scroll_down(lines); } } } pub(crate) fn pane_scroll_metrics( &self, terminal_runtimes: &TerminalRuntimeRegistry, pane_id: crate::layout::PaneId, ) -> Option { self.active .and_then(|i| self.runtime_for_pane_in_workspace(terminal_runtimes, i, pane_id)) .and_then(crate::terminal::TerminalRuntime::scroll_metrics) } fn handle_right_click_passthrough( &mut self, terminal_runtimes: &TerminalRuntimeRegistry, mouse: MouseEvent, in_sidebar: bool, ) -> bool { if let Some(gesture) = self.right_click_passthrough.clone() { match mouse.kind { MouseEventKind::Drag(MouseButton::Right) | MouseEventKind::Up(MouseButton::Right) => { let forwarded_mouse = self.strip_right_click_passthrough_modifiers(mouse, gesture.modifiers); let _ = self.forward_pane_mouse_button( terminal_runtimes, &gesture.pane_info, forwarded_mouse, ); if matches!(mouse.kind, MouseEventKind::Up(MouseButton::Right)) { self.right_click_passthrough = None; } return true; } _ => { self.right_click_passthrough = None; } } } if self.mode != Mode::Terminal || in_sidebar || !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right)) { return false; } let Some(modifiers) = self.right_click_passthrough_modifiers else { return false; }; if mouse.modifiers != modifiers { return false; } let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() else { return false; }; self.focus_pane(info.id); let forwarded_mouse = self.strip_right_click_passthrough_modifiers(mouse, modifiers); if !self.forward_pane_mouse_button(terminal_runtimes, &info, forwarded_mouse) { return false; } self.selection = None; self.selection_autoscroll = None; self.workspace_press = None; self.tab_press = None; self.drag = None; self.context_menu = None; self.right_click_passthrough = Some(RightClickPassthroughGesture { pane_info: info, modifiers, }); true } fn strip_right_click_passthrough_modifiers( &self, mouse: MouseEvent, modifiers: crossterm::event::KeyModifiers, ) -> MouseEvent { MouseEvent { modifiers: mouse.modifiers.difference(modifiers), ..mouse } } pub(super) fn handle_terminal_wheel( &mut self, terminal_runtimes: &TerminalRuntimeRegistry, mouse: MouseEvent, ) { let lines_per_notch = self.mouse_scroll_lines; if let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() { self.focus_pane(info.id); if self.forward_pane_wheel(terminal_runtimes, &info, mouse) { return; } match mouse.kind { MouseEventKind::ScrollUp => { self.scroll_pane_up(terminal_runtimes, info.id, lines_per_notch) } MouseEventKind::ScrollDown => { self.scroll_pane_down(terminal_runtimes, info.id, lines_per_notch) } _ => {} } return; } if let Some(info) = self.pane_frame_at(mouse.column, mouse.row).cloned() { self.focus_pane(info.id); match mouse.kind { MouseEventKind::ScrollUp => { self.scroll_pane_up(terminal_runtimes, info.id, lines_per_notch) } MouseEventKind::ScrollDown => { self.scroll_pane_down(terminal_runtimes, info.id, lines_per_notch) } _ => {} } return; } if let Some(ws_idx) = self.active { if let Some(rt) = self.focused_runtime_in_workspace(terminal_runtimes, ws_idx) { match mouse.kind { MouseEventKind::ScrollUp => rt.scroll_up(lines_per_notch), MouseEventKind::ScrollDown => rt.scroll_down(lines_per_notch), _ => {} } } } } pub(super) fn forward_pane_mouse_button( &self, terminal_runtimes: &TerminalRuntimeRegistry, info: &PaneInfo, mouse: MouseEvent, ) -> bool { let Some(ws_idx) = self.active else { return false; }; let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id) else { return false; }; let column = mouse.column.saturating_sub(info.inner_rect.x); let row = mouse.row.saturating_sub(info.inner_rect.y); let Some(bytes) = rt.encode_mouse_button(mouse.kind, column, row, mouse.modifiers) else { return false; }; rt.scroll_reset(); if let Err(err) = rt.try_send_bytes(Bytes::from(bytes)) { warn!(pane = info.id.raw(), err = %err, kind = ?mouse.kind, "failed to forward mouse button event"); } true } pub(super) fn forward_pane_mouse_motion( &self, terminal_runtimes: &TerminalRuntimeRegistry, info: &PaneInfo, mouse: MouseEvent, ) -> bool { let Some(ws_idx) = self.active else { return false; }; let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id) else { return false; }; let column = mouse.column.saturating_sub(info.inner_rect.x); let row = mouse.row.saturating_sub(info.inner_rect.y); let Some(bytes) = rt.encode_mouse_motion(mouse.kind, column, row, mouse.modifiers) else { return false; }; if let Err(err) = rt.try_send_bytes(Bytes::from(bytes)) { warn!(pane = info.id.raw(), err = %err, kind = ?mouse.kind, "failed to forward mouse motion event"); } true } fn forward_pane_reported_wheel( &self, terminal_runtimes: &TerminalRuntimeRegistry, info: &PaneInfo, mouse: MouseEvent, ) -> bool { let Some(ws_idx) = self.active else { return false; }; let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id) else { return false; }; if rt.wheel_routing() != Some(crate::pane::WheelRouting::MouseReport) { return false; } rt.scroll_reset(); let column = mouse.column.saturating_sub(info.inner_rect.x); let row = mouse.row.saturating_sub(info.inner_rect.y); let Some(bytes) = rt.encode_mouse_wheel(mouse.kind, column, row, mouse.modifiers) else { warn!(pane = info.id.raw(), kind = ?mouse.kind, "failed to encode mouse wheel event"); return true; }; if let Err(err) = rt.try_send_bytes(Bytes::from(bytes)) { warn!(pane = info.id.raw(), err = %err, "failed to forward mouse wheel event"); } true } pub(super) fn forward_pane_wheel( &self, terminal_runtimes: &TerminalRuntimeRegistry, info: &PaneInfo, mouse: MouseEvent, ) -> bool { let Some(ws_idx) = self.active else { return false; }; let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id) else { return false; }; match rt.wheel_routing() { Some(crate::pane::WheelRouting::HostScroll) | None => false, Some(crate::pane::WheelRouting::MouseReport) => { rt.scroll_reset(); let column = mouse.column.saturating_sub(info.inner_rect.x); let row = mouse.row.saturating_sub(info.inner_rect.y); let Some(bytes) = rt.encode_mouse_wheel(mouse.kind, column, row, mouse.modifiers) else { warn!(pane = info.id.raw(), kind = ?mouse.kind, "failed to encode mouse wheel event"); return true; }; if let Err(err) = rt.try_send_bytes(Bytes::from(bytes)) { warn!(pane = info.id.raw(), err = %err, "failed to forward mouse wheel event"); } true } Some(crate::pane::WheelRouting::AlternateScroll) => { rt.scroll_reset(); let Some(bytes) = rt.encode_alternate_scroll(mouse.kind) else { return true; }; if let Err(err) = rt.try_send_bytes(Bytes::from(bytes)) { warn!(pane = info.id.raw(), err = %err, "failed to forward alternate-scroll key"); } true } } } pub(super) fn set_pane_scroll_offset( &self, terminal_runtimes: &TerminalRuntimeRegistry, pane_id: crate::layout::PaneId, offset_from_bottom: usize, ) { if let Some(ws_idx) = self.active { if let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id) { rt.set_scroll_offset_from_bottom(offset_from_bottom); } } } pub(super) fn scrollbar_target_at( &self, terminal_runtimes: &TerminalRuntimeRegistry, col: u16, row: u16, ) -> Option<(crate::layout::PaneId, ScrollbarClickTarget)> { let ws_idx = self.active?; let info = self.view.pane_infos.iter().find(|info| { crate::ui::pane_scrollbar_rect(info).is_some_and(|track| { col >= track.x && col < track.x + track.width && row >= track.y && row < track.y + track.height }) })?; let rt = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id)?; let metrics = rt.scroll_metrics()?; if metrics.max_offset_from_bottom == 0 { return None; } let track = crate::ui::pane_scrollbar_rect(info)?; if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) { Some((info.id, ScrollbarClickTarget::Thumb { grab_row_offset })) } else { Some(( info.id, ScrollbarClickTarget::Track { offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row), }, )) } } pub(super) fn scrollbar_offset_for_pane_row( &self, terminal_runtimes: &TerminalRuntimeRegistry, pane_id: crate::layout::PaneId, row: u16, grab_row_offset: u16, ) -> Option { let ws_idx = self.active?; let info = self .view .pane_infos .iter() .find(|info| info.id == pane_id)?; let track = crate::ui::pane_scrollbar_rect(info)?; let rt = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id)?; let metrics = rt.scroll_metrics()?; if metrics.max_offset_from_bottom == 0 { return None; } Some(crate::ui::scrollbar_offset_from_drag_row( metrics, track, row, grab_row_offset, )) } } #[cfg(test)] pub(super) fn wheel_routing(input_state: crate::pane::InputState) -> WheelRouting { if input_state.mouse_protocol_mode.reporting_enabled() { WheelRouting::MouseReport } else if input_state.alternate_screen && input_state.mouse_alternate_scroll { WheelRouting::AlternateScroll } else { WheelRouting::HostScroll } } fn rect_contains(rect: Rect, col: u16, row: u16) -> bool { rect.width > 0 && rect.height > 0 && col >= rect.x && col < rect.x + rect.width && row >= rect.y && row < rect.y + rect.height } fn apply_scroll(scroll: &mut usize, delta: i16, max_scroll: usize) { if delta.is_negative() { *scroll = scroll.saturating_sub(delta.unsigned_abs() as usize); } else { *scroll = scroll.saturating_add(delta as usize).min(max_scroll); } } #[cfg(test)] mod tests { use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEventKind}; use ratatui::layout::{Direction, Rect}; use super::super::{ app_for_mouse_test, capture_snapshot, mouse, numbered_lines_bytes, root_layout_ratio, }; use super::*; use crate::app::input::modal::handle_context_menu_key; use crate::{ app::state::{ContextMenuKind, ContextMenuState, MenuListState, Mode, ViewLayout}, detect::{Agent, AgentState}, workspace::Workspace, }; #[tokio::test] async fn terminal_wheel_uses_configured_mouse_scroll_lines() { let mut app = app_for_mouse_test(); let mut ws = Workspace::test_new("test"); let pane_id = ws.tabs[0].root_pane; let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); let info = pane_infos[0].clone(); ws.tabs[0].runtimes.insert( pane_id, crate::terminal::TerminalRuntime::test_with_scrollback_bytes( info.inner_rect.width, info.inner_rect.height, 16 * 1024, &numbered_lines_bytes(64), ), ); app.state.workspaces = vec![ws]; app.state.active = Some(0); app.state.selected = 0; app.state.mode = Mode::Terminal; app.state.view.pane_infos = pane_infos; app.state.mouse_scroll_lines = 7; app.handle_mouse(mouse( MouseEventKind::ScrollUp, info.inner_rect.x + 1, info.inner_rect.y + 1, )); let metrics = app .state .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) .and_then(crate::terminal::TerminalRuntime::scroll_metrics) .expect("scroll metrics after wheel"); assert_eq!(metrics.offset_from_bottom, 7); } #[tokio::test] async fn configured_right_click_passthrough_forwards_full_gesture_to_pane() { let mut app = app_for_mouse_test(); let mut ws = Workspace::test_new("test"); let pane_id = ws.tabs[0].root_pane; let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); let info = pane_infos[0].clone(); let (runtime, mut input_rx) = crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( info.inner_rect.width, info.inner_rect.height, 0, b"\x1b[?1002h\x1b[?1006h", 4, ); ws.insert_test_runtime(pane_id, runtime); app.state.workspaces = vec![ws]; app.state.active = Some(0); app.state.selected = 0; app.state.mode = Mode::Terminal; app.state.view.pane_infos = pane_infos; app.state.right_click_passthrough_modifiers = Some(KeyModifiers::CONTROL); let col = info.inner_rect.x + 2; let row = info.inner_rect.y + 3; app.handle_mouse(MouseEvent { modifiers: KeyModifiers::CONTROL, ..mouse(MouseEventKind::Down(MouseButton::Right), col, row) }); app.handle_mouse(MouseEvent { modifiers: KeyModifiers::CONTROL, ..mouse(MouseEventKind::Drag(MouseButton::Right), col + 1, row + 1) }); app.handle_mouse(MouseEvent { modifiers: KeyModifiers::CONTROL, ..mouse(MouseEventKind::Up(MouseButton::Right), col + 1, row + 1) }); assert_eq!(app.state.mode, Mode::Terminal); assert!(app.state.context_menu.is_none()); assert!(app.state.right_click_passthrough.is_none()); assert_eq!( input_rx.try_recv().expect("forwarded right mouse down"), Bytes::from_static(b"\x1b[<2;3;4M") ); assert_eq!( input_rx.try_recv().expect("forwarded right mouse drag"), Bytes::from_static(b"\x1b[<34;4;5M") ); assert_eq!( input_rx.try_recv().expect("forwarded right mouse up"), Bytes::from_static(b"\x1b[<2;4;5m") ); assert!(input_rx.try_recv().is_err()); } #[tokio::test] async fn pane_mouse_only_forwards_moved_events_for_any_motion_apps() { let mut app = app_for_mouse_test(); let mut ws = Workspace::test_new("test"); let pane_id = ws.tabs[0].root_pane; let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); let info = pane_infos[0].clone(); let (runtime, mut input_rx) = crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( info.inner_rect.width, info.inner_rect.height, 0, b"\x1b[?1003h\x1b[?1006h", 4, ); ws.insert_test_runtime(pane_id, runtime); app.state.workspaces = vec![ws]; app.state.active = Some(0); app.state.selected = 0; app.state.mode = Mode::Terminal; app.state.view.pane_infos = pane_infos; app.state.handle_pane_mouse_only( &app.terminal_runtimes, mouse( MouseEventKind::Moved, info.inner_rect.x + 2, info.inner_rect.y + 3, ), ); assert_eq!( input_rx.try_recv().expect("forwarded mouse motion"), Bytes::from_static(b"\x1b[<35;3;4M") ); assert!(input_rx.try_recv().is_err()); } #[tokio::test] async fn pane_mouse_motion_uses_computed_inner_rect_offsets() { let mut app = app_for_mouse_test(); let mut ws = Workspace::test_new("test"); let pane_id = ws.tabs[0].root_pane; let (runtime, mut input_rx) = crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( 80, 18, 0, b"\x1b[?1003h\x1b[?1006h", 4, ); ws.insert_test_runtime(pane_id, runtime); app.state.workspaces = vec![ws]; app.state.active = Some(0); app.state.selected = 0; app.state.mode = Mode::Terminal; crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); let info = app.state.view.pane_infos[0].clone(); assert!(info.inner_rect.x > 0, "sidebar offset should be present"); assert!(info.inner_rect.y > 0, "tab bar offset should be present"); app.state.handle_pane_mouse_only( &app.terminal_runtimes, mouse( MouseEventKind::Moved, info.inner_rect.x + 2, info.inner_rect.y + 3, ), ); assert_eq!( input_rx.try_recv().expect("forwarded mouse motion"), Bytes::from_static(b"\x1b[<35;3;4M") ); assert!(input_rx.try_recv().is_err()); } #[tokio::test] async fn mouse_dispatcher_downgrades_sgr_pixel_motion_to_cell_coordinates() { let mut app = app_for_mouse_test(); let mut ws = Workspace::test_new("test"); let pane_id = ws.tabs[0].root_pane; let (runtime, mut input_rx) = crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( 80, 18, 0, b"\x1b[?1003h\x1b[?1006h\x1b[?1016h", 4, ); ws.insert_test_runtime(pane_id, runtime); app.state.workspaces = vec![ws]; app.state.active = Some(0); app.state.selected = 0; app.state.mode = Mode::Terminal; crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); let info = app.state.view.pane_infos[0].clone(); assert!(info.inner_rect.x > 0, "sidebar offset should be present"); assert!(info.inner_rect.y > 0, "tab bar offset should be present"); app.handle_mouse(mouse( MouseEventKind::Moved, info.inner_rect.x + 2, info.inner_rect.y + 3, )); assert_eq!( input_rx.try_recv().expect("forwarded mouse motion"), Bytes::from_static(b"\x1b[<35;3;4M") ); assert!(input_rx.try_recv().is_err()); } #[tokio::test] async fn mouse_dispatcher_does_not_forward_motion_behind_herdr_modes() { let mut app = app_for_mouse_test(); let mut ws = Workspace::test_new("test"); let pane_id = ws.tabs[0].root_pane; let (runtime, mut input_rx) = crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( 80, 18, 0, b"\x1b[?1003h\x1b[?1006h", 4, ); ws.insert_test_runtime(pane_id, runtime); app.state.workspaces = vec![ws]; app.state.active = Some(0); app.state.selected = 0; app.state.mode = Mode::Navigate; crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); let info = app.state.view.pane_infos[0].clone(); app.handle_mouse(mouse( MouseEventKind::Moved, info.inner_rect.x + 2, info.inner_rect.y + 3, )); assert!(input_rx.try_recv().is_err()); } #[tokio::test] async fn unset_right_click_passthrough_keeps_modified_right_click_as_herdr_menu() { let mut app = app_for_mouse_test(); let mut ws = Workspace::test_new("test"); let pane_id = ws.tabs[0].root_pane; let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); let info = pane_infos[0].clone(); let (runtime, mut input_rx) = crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( info.inner_rect.width, info.inner_rect.height, 0, b"\x1b[?1002h\x1b[?1006h", 4, ); ws.insert_test_runtime(pane_id, runtime); app.state.workspaces = vec![ws]; app.state.active = Some(0); app.state.selected = 0; app.state.mode = Mode::Terminal; app.state.view.pane_infos = pane_infos; app.state.right_click_passthrough_modifiers = None; app.handle_mouse(MouseEvent { modifiers: KeyModifiers::CONTROL, ..mouse( MouseEventKind::Down(MouseButton::Right), info.inner_rect.x + 2, info.inner_rect.y + 3, ) }); assert_eq!(app.state.mode, Mode::ContextMenu); assert!(app.state.context_menu.is_some()); assert!(app.state.right_click_passthrough.is_none()); assert!(input_rx.try_recv().is_err()); } #[tokio::test] async fn pane_right_click_keeps_focus_and_swap_menu_swaps_with_focused_pane() { let mut app = app_for_mouse_test(); let mut ws = Workspace::test_new("test"); let source = ws.tabs[0].root_pane; let target = ws.test_split(Direction::Horizontal); ws.tabs[0].layout.focus_pane(source); app.state.workspaces = vec![ws]; app.state.active = Some(0); app.state.selected = 0; app.state.mode = Mode::Terminal; crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 100, 20)); let target_info = app .state .view .pane_infos .iter() .find(|info| info.id == target) .expect("target pane info") .clone(); let source_rect_before = app .state .view .pane_infos .iter() .find(|info| info.id == source) .expect("source pane info") .rect; let target_rect_before = target_info.rect; app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Right), target_info.inner_rect.x, target_info.inner_rect.y, )); assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(source)); let menu = app.state.context_menu.as_mut().expect("pane context menu"); assert!(matches!( menu.kind, ContextMenuKind::Pane { pane_id, source_pane_id: Some(source_pane_id), .. } if pane_id == target && source_pane_id == source )); let swap_idx = menu .items() .iter() .position(|item| *item == "Swap with focused pane") .expect("swap item"); menu.list.highlighted = swap_idx; handle_context_menu_key( &mut app.state, &mut app.terminal_runtimes, KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), ); crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 100, 20)); assert_eq!(app.state.mode, Mode::Terminal); assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(source)); assert_eq!( app.state .view .pane_infos .iter() .find(|info| info.id == source) .unwrap() .rect, target_rect_before ); assert_eq!( app.state .view .pane_infos .iter() .find(|info| info.id == target) .unwrap() .rect, source_rect_before ); } #[tokio::test] async fn right_click_passthrough_requires_exact_modifier_match() { let mut app = app_for_mouse_test(); let mut ws = Workspace::test_new("test"); let pane_id = ws.tabs[0].root_pane; let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); let info = pane_infos[0].clone(); let (runtime, mut input_rx) = crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( info.inner_rect.width, info.inner_rect.height, 0, b"\x1b[?1002h\x1b[?1006h", 4, ); ws.insert_test_runtime(pane_id, runtime); app.state.workspaces = vec![ws]; app.state.active = Some(0); app.state.selected = 0; app.state.mode = Mode::Terminal; app.state.view.pane_infos = pane_infos; app.state.right_click_passthrough_modifiers = Some(KeyModifiers::CONTROL); let col = info.inner_rect.x + 2; let row = info.inner_rect.y + 3; app.handle_mouse(MouseEvent { modifiers: KeyModifiers::CONTROL | KeyModifiers::SHIFT, ..mouse(MouseEventKind::Down(MouseButton::Right), col, row) }); assert_eq!(app.state.mode, Mode::ContextMenu); assert!(app.state.context_menu.is_some()); assert!(app.state.right_click_passthrough.is_none()); assert!(input_rx.try_recv().is_err()); } #[tokio::test] async fn right_click_passthrough_does_not_forward_pane_frame_clicks() { let mut app = app_for_mouse_test(); let mut ws = Workspace::test_new("test"); let pane_id = ws.tabs[0].root_pane; let other_pane = ws.test_split(Direction::Vertical); app.state.workspaces = vec![ws]; app.state.active = Some(0); app.state.selected = 0; app.state.mode = Mode::Terminal; app.state.right_click_passthrough_modifiers = Some(KeyModifiers::CONTROL); crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); let info = app .state .view .pane_infos .iter() .find(|info| info.id == pane_id) .expect("pane info") .clone(); let (runtime, mut input_rx) = crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( info.inner_rect.width, info.inner_rect.height, 0, b"\x1b[?1002h\x1b[?1006h", 4, ); app.state.insert_test_runtime(pane_id, runtime); app.state.insert_test_runtime( other_pane, crate::terminal::TerminalRuntime::test_with_screen_bytes(10, 5, b""), ); assert!(app.state.pane_at(info.rect.x, info.rect.y).is_none()); assert!(app .state .pane_mouse_target(info.rect.x, info.rect.y) .is_some()); app.handle_mouse(MouseEvent { modifiers: KeyModifiers::CONTROL, ..mouse( MouseEventKind::Down(MouseButton::Right), info.rect.x, info.rect.y, ) }); assert_eq!(app.state.mode, Mode::ContextMenu); assert!(app.state.context_menu.is_some()); assert!(app.state.right_click_passthrough.is_none()); assert!(input_rx.try_recv().is_err()); } fn sample_worktree_open_state() -> crate::app::state::WorktreeOpenState { crate::app::state::WorktreeOpenState { source_workspace_id: "source".into(), source_existing_membership: None, source_checkout_path: "/repo/herdr".into(), source_repo_root: "/repo/herdr".into(), repo_key: "repo-key".into(), repo_name: "herdr".into(), entries: vec![ crate::app::state::WorktreeOpenEntry { path: "/repo/herdr".into(), branch: Some("main".into()), is_linked_worktree: false, already_open_ws_idx: Some(0), }, crate::app::state::WorktreeOpenEntry { path: "/repo/herdr-issue".into(), branch: Some("worktree/issue".into()), is_linked_worktree: true, already_open_ws_idx: None, }, ], selected: 0, query: String::new(), search_focused: false, error: None, } } #[test] fn hovering_context_menu_updates_highlight() { let mut app = app_for_mouse_test(); app.state.context_menu = Some(ContextMenuState { kind: ContextMenuKind::Workspace { ws_idx: 0 }, x: 2, y: 2, list: MenuListState::new(0), }); app.state.mode = Mode::ContextMenu; let menu = app.state.context_menu_rect().unwrap(); app.handle_mouse(mouse(MouseEventKind::Moved, menu.x + 2, menu.y + 2)); assert_eq!(app.state.context_menu.unwrap().list.highlighted, 1); } #[test] fn clicking_agent_toast_focuses_target_pane() { let mut app = app_for_mouse_test(); let active = Workspace::test_new("active"); let mut background = Workspace::test_new("background"); let first_pane = background.tabs[0].root_pane; let target_pane = background.test_split(Direction::Horizontal); background.tabs[0].layout.focus_pane(first_pane); app.state.workspaces = vec![active, background]; app.state.ensure_test_terminals(); app.state.active = Some(0); app.state.selected = 0; app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr; app.state.toast_config.delay_seconds = 0; let target_terminal_id = app.state.workspaces[1] .panes .get(&target_pane) .unwrap() .attached_terminal_id .clone(); app.state .terminals .get_mut(&target_terminal_id) .unwrap() .state = AgentState::Working; app.state .handle_app_event(crate::events::AppEvent::StateChanged { pane_id: target_pane, agent: Some(Agent::Pi), state: AgentState::Idle, visible_blocker: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), }); crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); let hit = app.state.view.toast_hit_area; app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), hit.x + 1, hit.y + 1, )); assert_eq!(app.state.active, Some(1)); assert_eq!(app.state.workspaces[1].focused_pane_id(), Some(target_pane)); assert!(app.state.toast.is_none()); assert_eq!(app.state.mode, Mode::Terminal); app.state.last_pane(); assert_eq!(app.state.active, Some(0)); assert_eq!( app.state.workspaces[0].focused_pane_id(), Some(app.state.workspaces[0].tabs[0].root_pane) ); } #[test] fn toast_click_does_not_steal_mouse_from_settings_overlay() { let mut app = app_for_mouse_test(); let active = Workspace::test_new("active"); let background = Workspace::test_new("background"); let target_pane = background.tabs[0].root_pane; let workspace_id = background.id.clone(); app.state.workspaces = vec![active, background]; app.state.active = Some(0); app.state.selected = 0; app.state.toast = Some(crate::app::state::ToastNotification { kind: crate::app::state::ToastKind::Finished, title: "pi finished".into(), context: "background ยท 2".into(), position: None, target: Some(crate::app::state::ToastTarget { workspace_id, pane_id: target_pane, }), }); app.state.mode = Mode::Settings; crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); let hit = app.state.view.toast_hit_area; app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), hit.x + 1, hit.y + 1, )); assert_eq!(app.state.active, Some(0)); assert!(app.state.toast.is_some()); } #[test] fn clicking_confirm_close_accepts_workspace_close() { let mut app = app_for_mouse_test(); app.state.workspaces = vec![Workspace::test_new("a"), Workspace::test_new("b")]; app.state.active = Some(0); app.state.selected = 1; app.state.mode = Mode::ConfirmClose; let popup = app.state.confirm_close_rect(); let inner = Rect::new( popup.x + 1, popup.y + 1, popup.width.saturating_sub(2), popup.height.saturating_sub(2), ); let (confirm, _) = crate::ui::confirm_close_button_rects(inner); app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), confirm.x, confirm.y, )); assert_eq!(app.state.workspaces.len(), 1); assert_eq!(app.state.mode, Mode::Terminal); } #[test] fn clicking_rename_save_submits_workspace_rename_through_api_path() { let mut app = app_for_mouse_test(); app.state.workspaces = vec![Workspace::test_new("old")]; app.state.active = Some(0); app.state.selected = 0; app.state.mode = Mode::RenameWorkspace; app.state.name_input = "new".into(); crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 24)); let inner = app.state.rename_modal_inner().unwrap(); let (save, _, _) = crate::ui::rename_button_rects(inner); app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), save.x, save.y, )); assert_eq!(app.state.workspaces[0].custom_name.as_deref(), Some("new")); assert!(app.event_hub.events_after(0).iter().any(|(_, event)| { matches!(event.event, crate::api::schema::EventKind::WorkspaceRenamed) })); } #[test] fn clicking_open_worktree_row_selects_and_requests_open() { let mut app = app_for_mouse_test(); app.state.mode = Mode::OpenExistingWorktree; app.state.worktree_open = Some(sample_worktree_open_state()); let inner = crate::ui::open_existing_worktree_inner_rect(app.state.screen_rect(), 2).unwrap(); app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), inner.x + 1, inner.y + 5, )); assert_eq!(app.state.worktree_open.as_ref().unwrap().selected, 1); assert!(app.state.request_submit_worktree_open); } #[test] fn clicking_open_worktree_buttons_requests_open_or_cancels() { let mut app = app_for_mouse_test(); app.state.mode = Mode::OpenExistingWorktree; app.state.worktree_open = Some(sample_worktree_open_state()); let inner = crate::ui::open_existing_worktree_inner_rect(app.state.screen_rect(), 2).unwrap(); let (open, _) = crate::ui::open_existing_worktree_button_rects(inner); app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), open.x, open.y, )); assert!(app.state.worktree_open.is_some()); assert!(app.state.request_submit_worktree_open); let mut app = app_for_mouse_test(); app.state.mode = Mode::OpenExistingWorktree; app.state.worktree_open = Some(sample_worktree_open_state()); let inner = crate::ui::open_existing_worktree_inner_rect(app.state.screen_rect(), 2).unwrap(); let (_, cancel) = crate::ui::open_existing_worktree_button_rects(inner); app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), cancel.x, cancel.y, )); assert!(app.state.worktree_open.is_none()); assert_eq!(app.state.mode, Mode::Navigate); } #[test] fn scrolling_open_worktree_picker_moves_selection() { let mut app = app_for_mouse_test(); app.state.mode = Mode::OpenExistingWorktree; app.state.worktree_open = Some(sample_worktree_open_state()); app.handle_mouse(mouse(MouseEventKind::ScrollDown, 1, 1)); assert_eq!(app.state.worktree_open.as_ref().unwrap().selected, 1); app.handle_mouse(mouse(MouseEventKind::ScrollUp, 1, 1)); assert_eq!(app.state.worktree_open.as_ref().unwrap().selected, 0); } #[test] fn clicking_remove_worktree_buttons_requests_remove_or_cancels() { let mut app = app_for_mouse_test(); app.state.mode = Mode::ConfirmRemoveWorktree; app.state.worktree_remove = Some(crate::app::state::WorktreeRemoveState { workspace_id: "issue".into(), repo_root: "/repo/herdr".into(), path: "/repo/herdr-issue".into(), error: None, removing: false, force_confirmation: false, }); let popup = crate::ui::remove_worktree_popup_rect(app.state.screen_rect()).unwrap(); let inner = Rect::new( popup.x + 1, popup.y + 1, popup.width.saturating_sub(2), popup.height.saturating_sub(2), ); let (remove, _) = crate::ui::remove_worktree_button_rects(inner, false); app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), remove.x, remove.y, )); assert!(app.state.worktree_remove.is_some()); assert!(app.state.request_submit_worktree_remove); let mut app = app_for_mouse_test(); app.state.mode = Mode::ConfirmRemoveWorktree; app.state.worktree_remove = Some(crate::app::state::WorktreeRemoveState { workspace_id: "issue".into(), repo_root: "/repo/herdr".into(), path: "/repo/herdr-issue".into(), error: None, removing: false, force_confirmation: false, }); let popup = crate::ui::remove_worktree_popup_rect(app.state.screen_rect()).unwrap(); let inner = Rect::new( popup.x + 1, popup.y + 1, popup.width.saturating_sub(2), popup.height.saturating_sub(2), ); let (_, cancel) = crate::ui::remove_worktree_button_rects(inner, false); app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), cancel.x, cancel.y, )); assert!(app.state.worktree_remove.is_none()); assert_eq!(app.state.mode, Mode::Navigate); } #[test] fn clicking_confirm_close_accepts_after_workspace_context_menu_close() { let mut app = app_for_mouse_test(); app.state.workspaces = vec![Workspace::test_new("a"), Workspace::test_new("b")]; app.state.active = Some(0); app.state.selected = 0; app.state.mode = Mode::Terminal; app.state.context_menu = Some(ContextMenuState { kind: ContextMenuKind::Workspace { ws_idx: 1 }, x: 2, y: 2, list: MenuListState::new(1), }); app.state.mode = Mode::ContextMenu; handle_context_menu_key( &mut app.state, &mut app.terminal_runtimes, KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), ); assert_eq!(app.state.mode, Mode::ConfirmClose); assert_eq!(app.state.selected, 1); let popup = app.state.confirm_close_rect(); let inner = Rect::new( popup.x + 1, popup.y + 1, popup.width.saturating_sub(2), popup.height.saturating_sub(2), ); let (confirm, _) = crate::ui::confirm_close_button_rects(inner); app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), confirm.x + 1, confirm.y, )); assert_eq!(app.state.workspaces.len(), 1); assert_eq!(app.state.workspaces[0].display_name(), "a"); } #[test] fn clicking_context_menu_close_routes_through_api_path() { let mut app = app_for_mouse_test(); app.state.workspaces = vec![Workspace::test_new("a"), Workspace::test_new("b")]; app.state.active = Some(0); app.state.selected = 0; app.state.confirm_close = false; app.state.context_menu = Some(ContextMenuState { kind: ContextMenuKind::Workspace { ws_idx: 1 }, x: 2, y: 2, list: MenuListState::new(1), }); app.state.mode = Mode::ContextMenu; let menu = app.state.context_menu_rect().unwrap(); app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), menu.x + 2, menu.y + 2, )); assert_eq!(app.state.workspaces.len(), 1); assert_eq!(app.state.workspaces[0].display_name(), "a"); assert!(app.event_hub.events_after(0).iter().any(|(_, event)| { matches!(event.event, crate::api::schema::EventKind::WorkspaceClosed) })); } #[cfg(unix)] #[tokio::test] async fn keyboard_context_menu_split_keeps_new_runtime() { let mut app = app_for_mouse_test(); app.state.default_shell = "/usr/bin/true".into(); let (workspace, terminal, runtime) = Workspace::new( std::env::current_dir().unwrap_or_else(|_| "/".into()), 24, 80, app.state.pane_scrollback_limit_bytes, app.state.host_terminal_theme, crate::pane::PaneShellConfig::new(&app.state.default_shell, app.state.shell_mode), app.event_tx.clone(), app.render_notify.clone(), app.render_dirty.clone(), ) .expect("workspace should spawn"); app.state.workspaces = vec![workspace]; app.terminal_runtimes.insert(terminal.id.clone(), runtime); app.state.terminals.insert(terminal.id.clone(), terminal); app.state.active = Some(0); app.state.selected = 0; let pane_id = app.state.workspaces[0].tabs[0].root_pane; let runtime_count = app.terminal_runtimes.len(); app.state.context_menu = Some(ContextMenuState { kind: ContextMenuKind::Pane { ws_idx: 0, tab_idx: 0, pane_id, source_pane_id: None, has_manual_label: false, }, x: 2, y: 2, list: MenuListState::new(1), }); app.state.mode = Mode::ContextMenu; handle_context_menu_key( &mut app.state, &mut app.terminal_runtimes, KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), ); assert_eq!(app.state.mode, Mode::Terminal); assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 2); assert_eq!(app.terminal_runtimes.len(), runtime_count + 1); let runtimes: Vec<_> = app.terminal_runtimes.drain().collect(); for (_terminal_id, runtime) in runtimes { runtime.shutdown(); } } #[test] fn dragging_pane_split_updates_captured_layout_ratio() { let mut app = app_for_mouse_test(); app.state.workspaces = vec![Workspace::test_new("test")]; app.state.active = Some(0); app.state.selected = 0; app.state.workspaces[0].test_split(Direction::Horizontal); crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); let border = app.state.view.split_borders[0].clone(); let before = capture_snapshot(&app.state); let drag_row = border.area.y.saturating_add(1); app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), border.pos, drag_row, )); app.handle_mouse(mouse( MouseEventKind::Drag(MouseButton::Left), border.pos.saturating_add(6), drag_row, )); let after = capture_snapshot(&app.state); assert_ne!(root_layout_ratio(&before), root_layout_ratio(&after)); } #[test] fn pane_split_hitbox_does_not_overlap_right_pane_content() { let mut app = app_for_mouse_test(); app.state.workspaces = vec![Workspace::test_new("test")]; app.state.active = Some(0); app.state.selected = 0; app.state.pane_gaps = false; app.state.workspaces[0].test_split(Direction::Horizontal); crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); let border = app.state.view.split_borders[0].clone(); let row = border.area.y.saturating_add(1); assert!(app .state .find_border_at(border.pos.saturating_sub(1), row) .is_none()); assert!(app.state.find_border_at(border.pos, row).is_some()); assert!(app .state .find_border_at(border.pos.saturating_add(1), row) .is_none()); } #[test] fn pane_split_hitbox_does_not_overlap_bottom_pane_content() { let mut app = app_for_mouse_test(); app.state.workspaces = vec![Workspace::test_new("test")]; app.state.active = Some(0); app.state.selected = 0; app.state.pane_gaps = false; app.state.workspaces[0].test_split(Direction::Vertical); crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); let border = app.state.view.split_borders[0].clone(); let col = border.area.x.saturating_add(1); assert!(app .state .find_border_at(col, border.pos.saturating_sub(1)) .is_none()); assert!(app.state.find_border_at(col, border.pos).is_some()); assert!(app .state .find_border_at(col, border.pos.saturating_add(1)) .is_none()); } #[test] fn borderless_no_gap_split_has_no_mouse_hitbox_over_content() { let mut app = app_for_mouse_test(); app.state.workspaces = vec![Workspace::test_new("test")]; app.state.active = Some(0); app.state.selected = 0; app.state.pane_borders = false; app.state.workspaces[0].test_split(Direction::Horizontal); crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); let border = app.state.view.split_borders[0].clone(); let row = border.area.y.saturating_add(1); assert!(app.state.find_border_at(border.pos, row).is_none()); } #[test] fn bordered_pane_gaps_keep_both_split_borders_draggable() { let mut app = app_for_mouse_test(); app.state.workspaces = vec![Workspace::test_new("test")]; app.state.active = Some(0); app.state.selected = 0; app.state.pane_gaps = true; app.state.workspaces[0].test_split(Direction::Horizontal); crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); let border = app.state.view.split_borders[0].clone(); let row = border.area.y.saturating_add(1); assert!(app .state .find_border_at(border.pos.saturating_sub(1), row) .is_some()); assert!(app.state.find_border_at(border.pos, row).is_some()); assert!(app .state .find_border_at(border.pos.saturating_add(1), row) .is_none()); } #[test] fn borderless_pane_gap_is_not_a_pane_but_remains_split_draggable() { let mut app = app_for_mouse_test(); app.state.workspaces = vec![Workspace::test_new("test")]; app.state.active = Some(0); app.state.selected = 0; app.state.pane_borders = false; app.state.pane_gaps = true; app.state.workspaces[0].test_split(Direction::Horizontal); crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); let border = app.state.view.split_borders[0].clone(); let row = border.area.y.saturating_add(1); let gap_col = border.pos.saturating_sub(1); assert!(app.state.pane_at(gap_col, row).is_none()); assert!(app.state.find_border_at(gap_col, row).is_some()); assert!(app.state.find_border_at(border.pos, row).is_none()); } #[test] fn borderless_gap_hitbox_is_empty_when_first_split_side_has_one_cell() { let mut app = app_for_mouse_test(); app.state.workspaces = vec![Workspace::test_new("test")]; app.state.active = Some(0); app.state.selected = 0; app.state.pane_borders = false; app.state.pane_gaps = true; app.state.workspaces[0].test_split(Direction::Horizontal); crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 2, 4)); let border = app.state.view.split_borders[0].clone(); let row = border.area.y.saturating_add(1); let candidate_gap_col = border.pos.saturating_sub(1); assert!(app.state.pane_frame_at(candidate_gap_col, row).is_some()); assert!(app.state.find_border_at(candidate_gap_col, row).is_none()); } #[test] fn borderless_gap_hitbox_is_empty_when_first_split_side_has_zero_width() { let mut app = app_for_mouse_test(); app.state.workspaces = vec![Workspace::test_new("test")]; app.state.active = Some(0); app.state.selected = 0; app.state.pane_borders = false; app.state.pane_gaps = true; app.state.workspaces[0].test_split(Direction::Horizontal); app.state.workspaces[0].tabs[0] .layout .set_ratio_at(&[], 0.1); crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 1, 4)); let border = app.state.view.split_borders[0].clone(); let row = border.area.y.saturating_add(1); assert_eq!(border.pos, 0); assert!(app.state.find_border_at(0, row).is_none()); } #[test] fn selecting_from_right_pane_first_content_column_starts_selection() { let mut app = app_for_mouse_test(); let mut ws = Workspace::test_new("test"); let second_pane = ws.test_split(Direction::Horizontal); app.state.workspaces = vec![ws]; app.state.active = Some(0); app.state.selected = 0; crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); let second_info = app .state .view .pane_infos .iter() .find(|info| info.id == second_pane) .expect("second pane info") .clone(); let col = second_info.inner_rect.x; let row = second_info.inner_rect.y; assert!(app.state.find_border_at(col, row).is_none()); app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), col, row)); assert!(app.state.drag.is_none()); assert_eq!( app.state .selection .as_ref() .map(|selection| selection.pane_id), Some(second_pane) ); } #[test] fn selecting_from_bottom_pane_first_content_row_starts_selection() { let mut app = app_for_mouse_test(); let mut ws = Workspace::test_new("test"); let second_pane = ws.test_split(Direction::Vertical); app.state.workspaces = vec![ws]; app.state.active = Some(0); app.state.selected = 0; crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); let second_info = app .state .view .pane_infos .iter() .find(|info| info.id == second_pane) .expect("second pane info") .clone(); let col = second_info.inner_rect.x; let row = second_info.inner_rect.y; assert!(app.state.find_border_at(col, row).is_none()); app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), col, row)); assert!(app.state.drag.is_none()); assert_eq!( app.state .selection .as_ref() .map(|selection| selection.pane_id), Some(second_pane) ); } #[tokio::test] async fn dragging_vertical_pane_split_still_resizes_when_pane_mouse_reporting_is_enabled() { let mut app = app_for_mouse_test(); let mut ws = Workspace::test_new("test"); let first_pane = ws.tabs[0].root_pane; let second_pane = ws.test_split(Direction::Vertical); app.state.workspaces = vec![ws]; app.state.active = Some(0); app.state.selected = 0; crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); let pane_infos = app.state.view.pane_infos.clone(); let first_info = pane_infos .iter() .find(|info| info.id == first_pane) .expect("first pane info") .clone(); let second_info = pane_infos .iter() .find(|info| info.id == second_pane) .expect("second pane info") .clone(); app.state.insert_test_runtime( first_pane, crate::terminal::TerminalRuntime::test_with_screen_bytes( first_info.inner_rect.width.max(1), first_info.inner_rect.height.max(1), b"\x1b[?1002h", ), ); app.state.insert_test_runtime( second_pane, crate::terminal::TerminalRuntime::test_with_screen_bytes( second_info.inner_rect.width.max(1), second_info.inner_rect.height.max(1), b"\x1b[?1002h", ), ); crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); let border = app .state .view .split_borders .iter() .find(|border| border.direction == Direction::Vertical) .expect("vertical split border") .clone(); let before = capture_snapshot(&app.state); let drag_col = border.area.x.saturating_add(1); app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), drag_col, border.pos, )); app.handle_mouse(mouse( MouseEventKind::Drag(MouseButton::Left), drag_col, border.pos.saturating_add(4), )); let after = capture_snapshot(&app.state); assert_ne!(root_layout_ratio(&before), root_layout_ratio(&after)); } #[tokio::test] async fn dragging_horizontal_pane_split_still_resizes_when_pane_mouse_reporting_is_enabled() { let mut app = app_for_mouse_test(); let mut ws = Workspace::test_new("test"); let first_pane = ws.tabs[0].root_pane; let second_pane = ws.test_split(Direction::Horizontal); app.state.workspaces = vec![ws]; app.state.active = Some(0); app.state.selected = 0; crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); let pane_infos = app.state.view.pane_infos.clone(); let first_info = pane_infos .iter() .find(|info| info.id == first_pane) .expect("first pane info") .clone(); let second_info = pane_infos .iter() .find(|info| info.id == second_pane) .expect("second pane info") .clone(); app.state.insert_test_runtime( first_pane, crate::terminal::TerminalRuntime::test_with_screen_bytes( first_info.inner_rect.width.max(1), first_info.inner_rect.height.max(1), b"\x1b[?1002h", ), ); app.state.insert_test_runtime( second_pane, crate::terminal::TerminalRuntime::test_with_screen_bytes( second_info.inner_rect.width.max(1), second_info.inner_rect.height.max(1), b"\x1b[?1002h", ), ); crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); let border = app .state .view .split_borders .iter() .find(|border| border.direction == Direction::Horizontal) .expect("horizontal split border") .clone(); let before = capture_snapshot(&app.state); let drag_row = border.area.y.saturating_add(1); app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), border.pos, drag_row, )); app.handle_mouse(mouse( MouseEventKind::Drag(MouseButton::Left), border.pos.saturating_add(6), drag_row, )); let after = capture_snapshot(&app.state); assert_ne!(root_layout_ratio(&before), root_layout_ratio(&after)); } #[test] fn wheel_routing_prefers_mouse_reporting() { let input_state = crate::pane::InputState { alternate_screen: true, application_cursor: false, bracketed_paste: false, focus_reporting: false, mouse_protocol_mode: crate::input::MouseProtocolMode::ButtonMotion, mouse_protocol_encoding: crate::input::MouseProtocolEncoding::Sgr, mouse_alternate_scroll: true, modify_other_keys: false, }; assert_eq!(wheel_routing(input_state), WheelRouting::MouseReport); } #[test] fn wheel_over_tab_bar_switches_tabs() { let mut app = app_for_mouse_test(); let mut ws = Workspace::test_new("one"); ws.test_add_tab(Some("two")); ws.test_add_tab(Some("three")); app.state.workspaces = vec![ws]; app.state.active = Some(0); app.state.selected = 0; app.state.mode = Mode::Terminal; crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); let tab_bar = app.state.view.tab_bar_rect; app.handle_mouse(mouse(MouseEventKind::ScrollDown, tab_bar.x + 1, tab_bar.y)); assert_eq!(app.state.workspaces[0].active_tab, 1); app.handle_mouse(mouse(MouseEventKind::ScrollUp, tab_bar.x + 1, tab_bar.y)); assert_eq!(app.state.workspaces[0].active_tab, 0); app.handle_mouse(mouse(MouseEventKind::ScrollUp, tab_bar.x + 1, tab_bar.y)); assert_eq!(app.state.workspaces[0].active_tab, 2); app.handle_mouse(mouse( MouseEventKind::ScrollDown, tab_bar.x + tab_bar.width.saturating_sub(1), tab_bar.y, )); assert_eq!(app.state.workspaces[0].active_tab, 0); } #[test] fn right_click_inactive_tab_opens_menu_without_switching_tabs() { let mut app = app_for_mouse_test(); let mut ws = Workspace::test_new("one"); ws.test_add_tab(Some("two")); app.state.workspaces = vec![ws]; app.state.active = Some(0); app.state.selected = 0; app.state.mode = Mode::Terminal; crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); let second_tab = app.state.view.tab_hit_areas[1]; app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Right), second_tab.x + 1, second_tab.y, )); assert_eq!(app.state.workspaces[0].active_tab, 0); let menu = app.state.context_menu.as_ref().expect("tab context menu"); assert_eq!( menu.kind, ContextMenuKind::Tab { ws_idx: 0, tab_idx: 1 } ); assert_eq!(app.state.mode, Mode::ContextMenu); } #[test] fn wheel_over_overflowing_tab_bar_switches_tabs() { let mut app = app_for_mouse_test(); let mut ws = Workspace::test_new("one"); ws.tabs[0].set_custom_name("very-long-one".into()); ws.test_add_tab(Some("very-long-two")); ws.test_add_tab(Some("very-long-three")); app.state.workspaces = vec![ws]; app.state.active = Some(0); app.state.selected = 0; app.state.mode = Mode::Terminal; crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 65, 20)); assert!(app.state.view.tab_scroll_right_hit_area.width > 0); let tab_bar = app.state.view.tab_bar_rect; app.handle_mouse(mouse( MouseEventKind::ScrollDown, tab_bar.x + tab_bar.width.saturating_sub(2), tab_bar.y, )); assert_eq!(app.state.workspaces[0].active_tab, 1); app.handle_mouse(mouse( MouseEventKind::ScrollDown, tab_bar.x + tab_bar.width.saturating_sub(2), tab_bar.y, )); assert_eq!(app.state.workspaces[0].active_tab, 2); } #[test] fn wheel_outside_tab_bar_does_not_switch_tabs() { let mut app = app_for_mouse_test(); let mut ws = Workspace::test_new("one"); ws.test_add_tab(Some("two")); app.state.workspaces = vec![ws]; app.state.active = Some(0); app.state.selected = 0; app.state.mode = Mode::Terminal; crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); let terminal = app.state.view.terminal_area; app.handle_mouse(mouse( MouseEventKind::ScrollDown, terminal.x + 1, terminal.y + 1, )); assert_eq!(app.state.workspaces[0].active_tab, 0); } #[test] fn mobile_switch_button_opens_switcher_and_workspace_row_switches_workspace() { let mut app = app_for_mouse_test(); app.state.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")]; app.state.active = Some(0); app.state.selected = 0; app.state.mode = Mode::Terminal; crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 20)); assert_eq!(app.state.view.layout, ViewLayout::Mobile); let switch = app.state.view.mobile_menu_hit_area; app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), switch.x + 1, switch.y + 1, )); assert_eq!(app.state.mode, Mode::Navigate); let viewport = crate::ui::mobile_switcher_areas(&app.state).viewport; app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), viewport.x + 2, viewport.y + 4, )); assert_eq!(app.state.active, Some(1)); assert_eq!(app.state.mode, Mode::Terminal); } #[test] fn mobile_workspace_panel_scroll_reaches_extra_workspaces() { let mut app = app_for_mouse_test(); app.state.workspaces = (0..12) .map(|idx| Workspace::test_new(&format!("ws-{idx}"))) .collect(); app.state.active = Some(0); app.state.selected = 0; app.state.mode = Mode::Terminal; crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 20)); let switch = app.state.view.mobile_menu_hit_area; app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), switch.x + 1, switch.y + 1, )); assert_eq!(app.state.mode, Mode::Navigate); let viewport = crate::ui::mobile_switcher_areas(&app.state).viewport; app.handle_mouse(mouse( MouseEventKind::ScrollDown, viewport.x + 2, viewport.y, )); crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 20)); assert_eq!(app.state.mobile_switcher_scroll, 2); app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), viewport.x + 2, viewport.y + 2, )); assert_eq!(app.state.active, Some(1)); assert_eq!(app.state.mode, Mode::Terminal); } #[test] fn mobile_global_scroll_reaches_tabs_and_switches_tab() { let mut app = app_for_mouse_test(); let mut ws = Workspace::test_new("one"); ws.test_add_tab(Some("two")); ws.test_add_tab(Some("three")); ws.test_add_tab(Some("four")); app.state.workspaces = vec![ws]; app.state.active = Some(0); app.state.selected = 0; app.state.mode = Mode::Terminal; crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 12)); let switch = app.state.view.mobile_menu_hit_area; app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), switch.x + 1, switch.y + 1, )); let viewport = crate::ui::mobile_switcher_areas(&app.state).viewport; app.handle_mouse(mouse( MouseEventKind::ScrollDown, viewport.x + 2, viewport.y, )); app.handle_mouse(mouse( MouseEventKind::ScrollDown, viewport.x + 2, viewport.y, )); assert_eq!(app.state.mobile_switcher_scroll, 4); app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), viewport.x + 2, viewport.y + 4, )); assert_eq!(app.state.workspaces[0].active_tab, 2); } #[test] fn mobile_switcher_action_rows_create_workspace_and_open_tab_dialog() { let mut app = app_for_mouse_test(); let mut ws = Workspace::test_new("one"); ws.test_add_tab(Some("logs")); app.state.workspaces = vec![ws]; app.state.active = Some(0); app.state.selected = 0; app.state.mode = Mode::Terminal; crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 20)); let switch = app.state.view.mobile_menu_hit_area; app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), switch.x + 1, switch.y + 1, )); let viewport = crate::ui::mobile_switcher_areas(&app.state).viewport; app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), viewport.x + 2, viewport.y + 1, )); assert!(app.state.request_new_workspace); app.state.request_new_workspace = false; app.state.mode = Mode::Navigate; app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), viewport.x + 2, viewport.y + 5, )); assert_eq!(app.state.mode, Mode::RenameTab); assert!(app.state.creating_new_tab); } #[test] fn mobile_switcher_new_tab_skips_dialog_when_prompt_disabled() { let mut app = app_for_mouse_test(); let mut ws = Workspace::test_new("one"); ws.test_add_tab(Some("logs")); app.state.workspaces = vec![ws]; app.state.active = Some(0); app.state.selected = 0; app.state.mode = Mode::Terminal; app.state.prompt_new_tab_name = false; crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 20)); let switch = app.state.view.mobile_menu_hit_area; app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), switch.x + 1, switch.y + 1, )); let viewport = crate::ui::mobile_switcher_areas(&app.state).viewport; app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), viewport.x + 2, viewport.y + 5, )); assert_eq!(app.state.mode, Mode::Terminal); assert!(!app.state.creating_new_tab); assert!(app.state.request_new_tab); assert!(app.state.requested_new_tab_name.is_none()); } #[test] fn desktop_new_tab_button_skips_dialog_when_prompt_disabled() { let mut app = app_for_mouse_test(); app.state.workspaces = vec![Workspace::test_new("one")]; app.state.active = Some(0); app.state.selected = 0; app.state.mode = Mode::Terminal; app.state.prompt_new_tab_name = false; crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 120, 40)); let new_tab_area = app.state.view.new_tab_hit_area; app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), new_tab_area.x + 1, new_tab_area.y, )); assert_eq!(app.state.mode, Mode::Terminal); assert!(!app.state.creating_new_tab); assert!(app.state.request_new_tab); assert!(app.state.requested_new_tab_name.is_none()); } #[test] fn mobile_switcher_swallows_non_left_mouse_events() { let mut app = app_for_mouse_test(); app.state.workspaces = vec![Workspace::test_new("one")]; app.state.active = Some(0); app.state.selected = 0; app.state.mode = Mode::Terminal; crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 20)); let switch = app.state.view.mobile_menu_hit_area; app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), switch.x + 1, switch.y + 1, )); assert_eq!(app.state.mode, Mode::Navigate); let viewport = crate::ui::mobile_switcher_areas(&app.state).viewport; app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Right), viewport.x + 2, viewport.y + 2, )); assert_eq!(app.state.mode, Mode::Navigate); assert!(app.state.context_menu.is_none()); } #[test] fn mobile_switch_button_does_not_bypass_rename_modal() { let mut app = app_for_mouse_test(); app.state.workspaces = vec![Workspace::test_new("one")]; app.state.active = Some(0); app.state.selected = 0; app.state.mode = Mode::RenameTab; app.state.creating_new_tab = true; app.state.name_input = "new tab".into(); crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 20)); let switch = app.state.view.mobile_menu_hit_area; app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), switch.x + 1, switch.y + 1, )); assert_eq!(app.state.mode, Mode::Terminal); assert!(!app.state.creating_new_tab); assert!(!app.state.request_new_tab); } #[test] fn mobile_switcher_close_returns_to_terminal() { let mut app = app_for_mouse_test(); app.state.workspaces = vec![Workspace::test_new("one")]; app.state.active = Some(0); app.state.selected = 0; app.state.mode = Mode::Terminal; crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 20)); let switch = app.state.view.mobile_menu_hit_area; app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), switch.x + 1, switch.y + 1, )); assert_eq!(app.state.mode, Mode::Navigate); let close = crate::ui::mobile_switcher_areas(&app.state).close; app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), close.x + 1, close.y, )); assert_eq!(app.state.mode, Mode::Terminal); } #[test] fn wheel_routing_uses_alternate_scroll_in_fullscreen_without_mouse_reporting() { let input_state = crate::pane::InputState { alternate_screen: true, application_cursor: false, bracketed_paste: false, focus_reporting: false, mouse_protocol_mode: crate::input::MouseProtocolMode::None, mouse_protocol_encoding: crate::input::MouseProtocolEncoding::Default, mouse_alternate_scroll: true, modify_other_keys: false, }; assert_eq!(wheel_routing(input_state), WheelRouting::AlternateScroll); } #[test] fn wheel_routing_falls_back_to_host_scrollback() { let input_state = crate::pane::InputState { alternate_screen: false, application_cursor: false, bracketed_paste: false, focus_reporting: false, mouse_protocol_mode: crate::input::MouseProtocolMode::None, mouse_protocol_encoding: crate::input::MouseProtocolEncoding::Default, mouse_alternate_scroll: true, modify_other_keys: false, }; assert_eq!(wheel_routing(input_state), WheelRouting::HostScroll); } }