diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-01 15:48:22 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-01 15:48:22 +0300 |
| commit | 207be3c771d281baae6e5fa0fb74be9a056e97a2 (patch) | |
| tree | d7b037ecdf629224a048c841d11659efdc23507d /src/app/input/mouse.rs | |
| parent | 8a6d697308162874a236c84aac0b0f8c7ac01f83 (diff) | |
refactor: render the shell in the client (#3487)
* feat: add opt-in client-rendered shell
* feat: add client-owned shell mouse controls
* feat: complete client-owned shell chrome
* feat: add client-owned shell settings and notifications
* feat: run client shell custom commands
* feat: render popup terminals in client shell
* feat: add client-owned scrollback and copy mode
* fix: preserve client worktree trust defaults
* feat: render startup diagnostics in client shell
* feat: render onboarding in client shell
* feat: render product announcements in client shell
* feat: render release notes in client shell
* feat: render mobile switcher in client shell
* feat: render kitty graphics in client shell
* fix: preserve client shell lifecycle coherence
* refactor: route client shell commands through endpoint connections
* refactor: make the client-rendered shell the default
* refactor: remove monolithic launch mode
* refactor: complete client-rendered shell cutover
* perf: retain client pane surface updates
* perf: scale retained pane surface updates
* perf: remove remote pane input latency
* test: make client shell checks platform-independent
* fix: prevent client timer starvation
* test: fix client shell platform gates
* test: accept retained surfaces in cross-area checks
* test: make client mode assertions platform-neutral
* fix: badge only outdated integrations
Diffstat (limited to 'src/app/input/mouse.rs')
| -rw-r--r-- | src/app/input/mouse.rs | 4778 |
1 files changed, 0 insertions, 4778 deletions
diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs deleted file mode 100644 index bf82126b..00000000 --- a/src/app/input/mouse.rs +++ /dev/null @@ -1,4778 +0,0 @@ -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 { - NewWorkspace, - 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, - }, - MoveWorkspaceBlock { - params: crate::api::schema::WorkspaceMoveBlockParams, - }, - MoveTab { - ws_idx: usize, - source_tab_idx: usize, - insert_idx: usize, - }, - SetSplitRatio { - path: Vec<bool>, - 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, - source_id: crate::app::InputSourceId, - mouse: MouseEvent, - ) -> Option<MouseAction> { - 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, source_id, 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.clear_chrome_press(source_id); - - 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.mode_bar_covers_tab_row(mouse.column, mouse.row) { - 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_presses.insert( - source_id, - 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 { - return Some(MouseAction::NewWorkspace); - } - - 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| { - let chevron = crate::ui::workspace_group_chevron_rect(card); - mouse.row == chevron.y && mouse.column == chevron.x && chevron.width > 0 - }) { - 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_presses.insert( - source_id, - 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; - return self.mouse_pane_focus_action(info.id); - } - - 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), - )); - return self.mouse_pane_focus_action(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; - } - return self.mouse_pane_focus_action(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() || self.chrome_drag_owned_by_other(source_id)) - && !self.chrome_press_pending(source_id) - { - 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_target = self.workspace_drop_target_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_presses.get(&source_id) { - 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_or(|space| !space.is_linked_worktree) - }); - if workspace_drop_target.is_some() - && can_reorder - && delta_col.max(delta_row) >= WORKSPACE_DRAG_THRESHOLD - { - self.drag = Some(DragState { - target: DragTarget::WorkspaceReorder { - source_id, - source_ws_idx: press.ws_idx, - drop_target: workspace_drop_target, - }, - }); - } - } else if let Some(press) = self.tab_presses.get(&source_id) { - let delta_col = mouse.column.abs_diff(press.start_col); - let delta_row = mouse.row.abs_diff(press.start_row); - // Require a real drop target before opening a reorder, - // so a report from off the tab bar cannot start a drag - // that has nowhere to land. - if tab_drop_index.is_some() - && delta_col.max(delta_row) >= TAB_DRAG_THRESHOLD - { - self.drag = Some(DragState { - target: DragTarget::TabReorder { - source_id, - ws_idx: press.ws_idx, - source_tab_idx: press.tab_idx, - insert_idx: tab_drop_index, - }, - }); - } - } - } - - if let Some(DragState { - target: - DragTarget::WorkspaceReorder { - source_id: drag_source_id, - drop_target, - .. - }, - }) = &mut self.drag - { - if *drag_source_id == source_id { - *drop_target = workspace_drop_target; - } - } else if let Some(DragState { - target: - DragTarget::TabReorder { - source_id: drag_source_id, - ws_idx, - insert_idx, - .. - }, - }) = &mut self.drag - { - if *drag_source_id == source_id && 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 word selection; the latter is already finalized. - if let Some(selection) = self.selection.as_ref() { - let was_click = selection.was_just_click(); - let was_finalized = selection.is_finalized(); - - self.clear_chrome_press(source_id); - self.drag = None; - self.selection_autoscroll = None; - if was_click { - self.selection = None; - } else if was_finalized { - // Double-click already finalized this word selection. - } else if self.copy_on_select { - self.copy_selection(terminal_runtimes); - } else if let Some(selection) = self.selection.as_mut() { - selection.finish(); - } - return None; - } - - let foreign_chrome_drag = self.chrome_drag_owned_by_other(source_id); - if (self.drag.is_none() || foreign_chrome_drag) - && !self.chrome_press_pending(source_id) - { - 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_press = self.workspace_presses.remove(&source_id); - let tab_press = self.tab_presses.remove(&source_id); - if foreign_chrome_drag { - return self.chrome_press_action(workspace_press, tab_press); - } - - match self.drag.take() { - Some(DragState { - target: - DragTarget::WorkspaceReorder { - source_ws_idx, - drop_target: Some(drop_target), - .. - }, - }) => { - if let Some(params) = - self.workspace_move_block_params(source_ws_idx, drop_target) - { - if self - .workspaces - .get(source_ws_idx) - .is_some_and(|workspace| workspace.worktree_space().is_some()) - { - return Some(MouseAction::MoveWorkspaceBlock { params }); - } - let insert_idx = params - .before_workspace_id - .as_ref() - .and_then(|id| { - self.workspaces - .iter() - .position(|workspace| workspace.id == *id) - }) - .unwrap_or(self.workspaces.len()); - 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 => return self.chrome_press_action(workspace_press, tab_press), - } - } - - 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.mode_bar_covers_tab_row(mouse.column, mouse.row) => {} - - 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::ScrollLeft | MouseEventKind::ScrollRight - if self.mode == Mode::Terminal && !in_sidebar => - { - if let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() { - self.forward_pane_reported_wheel(terminal_runtimes, &info, 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.clear_chrome_press(source_id); - 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.mode_bar_covers_tab_row(mouse.column, mouse.row) - && 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 pane_state = self - .workspaces - .get(ws_idx) - .and_then(|ws| ws.pane_state(info.id)); - let has_manual_label = pane_state - .and_then(|pane| self.terminals.get(&pane.attached_terminal_id)) - .and_then(|terminal| terminal.manual_label.as_ref()) - .is_some(); - let right_click_passthrough = - pane_state.is_some_and(|pane| pane.right_click_passthrough); - self.context_menu = Some(ContextMenuState { - kind: ContextMenuKind::Pane { - ws_idx, - tab_idx, - pane_id: info.id, - source_pane_id, - has_manual_label, - right_click_passthrough, - }, - 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) => { - return MobileMouseResult::Action(MouseAction::NewWorkspace); - } - 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<Rect> { - 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<usize> { - 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<usize> { - 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) - }) - } - - fn mode_bar_covers_tab_row(&self, col: u16, row: u16) -> bool { - self.tab_bar_position == crate::config::TabBarPositionConfig::Bottom - && matches!( - self.mode, - Mode::Navigate | Mode::Prefix | Mode::Copy | Mode::Resize - ) - && self.on_tab_bar(col, row) - } - - 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<usize> { - 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)) - } - - fn chrome_press_pending(&self, source_id: crate::app::InputSourceId) -> bool { - self.tab_presses.contains_key(&source_id) || self.workspace_presses.contains_key(&source_id) - } - - fn chrome_drag_owned_by_other(&self, source_id: crate::app::InputSourceId) -> bool { - self.drag.as_ref().is_some_and(|drag| { - matches!( - drag.target, - DragTarget::WorkspaceReorder { - source_id: drag_source_id, - .. - } | DragTarget::TabReorder { - source_id: drag_source_id, - .. - } if drag_source_id != source_id - ) - }) - } - - fn chrome_press_action( - &mut self, - workspace_press: Option<WorkspacePressState>, - tab_press: Option<TabPressState>, - ) -> Option<MouseAction> { - 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, - }); - } - } - None - } - - pub(crate) fn clear_chrome_gesture(&mut self, source_id: crate::app::InputSourceId) { - if self.drag.as_ref().is_some_and(|drag| { - matches!( - drag.target, - DragTarget::WorkspaceReorder { - source_id: drag_source_id, - .. - } | DragTarget::TabReorder { - source_id: drag_source_id, - .. - } if drag_source_id == source_id - ) - }) { - self.drag = None; - } - self.clear_chrome_press(source_id); - } - - fn clear_chrome_press(&mut self, source_id: crate::app::InputSourceId) { - self.tab_presses.remove(&source_id); - self.workspace_presses.remove(&source_id); - } - - fn mouse_pane_focus_action(&self, pane_id: crate::layout::PaneId) -> Option<MouseAction> { - let ws_idx = self.active?; - (self - .workspaces - .get(ws_idx) - .and_then(|workspace| workspace.focused_pane_id()) - != Some(pane_id)) - .then_some(MouseAction::FocusPane { ws_idx, pane_id }) - } - - 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) - } - - #[cfg(test)] - 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.settle_terminal_mode_after_focus(); - } - - 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<crate::pane::ScrollMetrics> { - 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, - source_id: crate::app::InputSourceId, - 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(info) = self.pane_at(mouse.column, mouse.row).cloned() else { - return false; - }; - let configured_modifiers = self - .right_click_passthrough_modifiers - .filter(|modifiers| mouse.modifiers == *modifiers); - let pane_passthrough = mouse.modifiers.is_empty() - && self.active.is_some_and(|ws_idx| { - self.workspaces - .get(ws_idx) - .and_then(|workspace| workspace.pane_state(info.id)) - .is_some_and(|pane| pane.right_click_passthrough) - }); - let Some(modifiers) = configured_modifiers - .or_else(|| pane_passthrough.then(crossterm::event::KeyModifiers::empty)) - 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.clear_chrome_press(source_id); - 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), - _ => {} - } - } - } - } - - fn pane_mouse_position( - &self, - runtime: &crate::terminal::TerminalRuntime, - inner: Rect, - mouse: MouseEvent, - ) -> Option<crate::input::mouse::Position> { - let column = mouse.column.saturating_sub(inner.x); - let row = mouse.row.saturating_sub(inner.y); - let cell = crate::input::mouse::Position::Cell { column, row }; - let Some(host) = self.host_mouse_pixels else { - return Some(cell); - }; - let wants_pixels = runtime.sgr_pixel_mouse_enabled(); - if !wants_pixels { - return Some(cell); - } - let Some((width_px, height_px)) = runtime.pixel_size() else { - return Some(cell); - }; - Some( - host.pane_position(inner, width_px, height_px) - .unwrap_or(cell), - ) - } - - 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 Some(position) = self.pane_mouse_position(rt, info.inner_rect, mouse) else { - return false; - }; - let Some(bytes) = rt.encode_mouse_button(mouse.kind, position, 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 Some(position) = self.pane_mouse_position(rt, info.inner_rect, mouse) else { - return false; - }; - let Some(bytes) = rt.encode_mouse_motion(mouse.kind, position, 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 Some(position) = self.pane_mouse_position(rt, info.inner_rect, mouse) else { - return false; - }; - let Some(bytes) = rt.encode_mouse_wheel(mouse.kind, position, 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) => { - self.forward_pane_reported_wheel(terminal_runtimes, info, mouse) - } - 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, - ) { - for ws_idx in 0..self.workspaces.len() { - let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id) - else { - continue; - }; - rt.set_scroll_offset_from_bottom(offset_from_bottom); - return; - } - } - - 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<usize> { - 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, - }; - - #[test] - fn tab_click_survives_stray_drag_report_off_the_tab_bar() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - ws.test_add_tab(None); - ws.active_tab = 1; - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - let area = Rect::new(0, 0, 106, 20); - crate::ui::compute_view(&mut app.state, area); - - let first_tab = app.state.view.tab_hit_areas[0]; - let press_col = first_tab.x + 1; - let stray_row = area.height - 1; - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - press_col, - first_tab.y, - )); - app.handle_mouse(mouse( - MouseEventKind::Drag(MouseButton::Left), - press_col, - stray_row, - )); - app.handle_mouse(mouse( - MouseEventKind::Up(MouseButton::Left), - press_col, - stray_row, - )); - - assert_eq!(app.state.workspaces[0].active_tab, 0); - } - - #[test] - fn workspace_click_survives_stray_drag_report_off_the_workspace_list() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("first"), Workspace::test_new("second")]; - app.state.active = Some(1); - app.state.selected = 1; - let area = Rect::new(0, 0, 106, 20); - crate::ui::compute_view(&mut app.state, area); - - let first_workspace = app.state.view.workspace_card_areas[0]; - let press_col = first_workspace.rect.x + 1; - let press_row = first_workspace.rect.y; - let stray_row = area.height - 1; - assert!(app.state.workspace_drop_target_at_row(stray_row).is_none()); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - press_col, - press_row, - )); - app.handle_mouse(mouse( - MouseEventKind::Drag(MouseButton::Left), - press_col, - stray_row, - )); - app.handle_mouse(mouse( - MouseEventKind::Up(MouseButton::Left), - press_col, - stray_row, - )); - - assert_eq!(app.state.active, Some(0)); - } - - #[test] - fn concurrent_input_sources_keep_their_tab_clicks() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - ws.test_add_tab(None); - ws.test_add_tab(None); - ws.active_tab = 2; - 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 first_tab = app.state.view.tab_hit_areas[0]; - let second_tab = app.state.view.tab_hit_areas[1]; - app.handle_mouse_from_input_source( - 41, - mouse( - MouseEventKind::Down(MouseButton::Left), - first_tab.x + 1, - first_tab.y, - ), - ); - app.handle_mouse_from_input_source( - 42, - mouse( - MouseEventKind::Down(MouseButton::Left), - second_tab.x + 1, - second_tab.y, - ), - ); - - app.handle_mouse_from_input_source( - 41, - mouse( - MouseEventKind::Up(MouseButton::Left), - first_tab.x + 1, - first_tab.y, - ), - ); - assert_eq!(app.state.workspaces[0].active_tab, 0); - - app.handle_mouse_from_input_source( - 42, - mouse( - MouseEventKind::Up(MouseButton::Left), - second_tab.x + 1, - second_tab.y, - ), - ); - assert_eq!(app.state.workspaces[0].active_tab, 1); - } - - #[test] - fn concurrent_input_sources_keep_their_workspace_clicks() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![ - Workspace::test_new("first"), - Workspace::test_new("second"), - Workspace::test_new("third"), - ]; - app.state.active = Some(2); - app.state.selected = 2; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - - let first = app.state.view.workspace_card_areas[0].rect; - let second = app.state.view.workspace_card_areas[1].rect; - app.handle_mouse_from_input_source( - 41, - mouse( - MouseEventKind::Down(MouseButton::Left), - first.x + 1, - first.y, - ), - ); - app.handle_mouse_from_input_source( - 42, - mouse( - MouseEventKind::Down(MouseButton::Left), - second.x + 1, - second.y, - ), - ); - - app.handle_mouse_from_input_source( - 41, - mouse(MouseEventKind::Up(MouseButton::Left), first.x + 1, first.y), - ); - assert_eq!(app.state.active, Some(0)); - - app.handle_mouse_from_input_source( - 42, - mouse( - MouseEventKind::Up(MouseButton::Left), - second.x + 1, - second.y, - ), - ); - assert_eq!(app.state.active, Some(1)); - } - - #[test] - fn tab_click_completes_while_other_source_reorders() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - ws.test_add_tab(None); - ws.test_add_tab(None); - ws.active_tab = 2; - 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 first_tab = app.state.view.tab_hit_areas[0]; - let second_tab = app.state.view.tab_hit_areas[1]; - let last_tab = app.state.view.tab_hit_areas[2]; - let drop_col = last_tab.x + last_tab.width; - app.handle_mouse_from_input_source( - 41, - mouse( - MouseEventKind::Down(MouseButton::Left), - first_tab.x + 1, - first_tab.y, - ), - ); - app.handle_mouse_from_input_source( - 41, - mouse( - MouseEventKind::Drag(MouseButton::Left), - drop_col, - first_tab.y, - ), - ); - app.handle_mouse_from_input_source( - 42, - mouse( - MouseEventKind::Down(MouseButton::Left), - second_tab.x + 1, - second_tab.y, - ), - ); - - app.handle_mouse_from_input_source( - 42, - mouse( - MouseEventKind::Up(MouseButton::Left), - second_tab.x + 1, - second_tab.y, - ), - ); - assert_eq!(app.state.workspaces[0].active_tab, 1); - assert!(matches!( - app.state.drag.as_ref().map(|drag| &drag.target), - Some(DragTarget::TabReorder { source_id: 41, .. }) - )); - - app.handle_mouse_from_input_source( - 41, - mouse(MouseEventKind::Up(MouseButton::Left), drop_col, first_tab.y), - ); - assert!(app.state.drag.is_none()); - } - - #[test] - fn releasing_input_source_clears_only_its_pending_tab_click() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - ws.test_add_tab(None); - 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 first_tab = app.state.view.tab_hit_areas[0]; - let second_tab = app.state.view.tab_hit_areas[1]; - app.handle_mouse_from_input_source( - 41, - mouse( - MouseEventKind::Down(MouseButton::Left), - first_tab.x + 1, - first_tab.y, - ), - ); - app.handle_mouse_from_input_source( - 42, - mouse( - MouseEventKind::Down(MouseButton::Left), - second_tab.x + 1, - second_tab.y, - ), - ); - - app.clear_input_source(41); - - assert!(!app.state.tab_presses.contains_key(&41)); - assert!(app.state.tab_presses.contains_key(&42)); - } - - #[tokio::test] - async fn other_input_source_pane_gesture_is_not_swallowed_by_chrome_press() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - ws.test_add_tab(None); - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - let area = Rect::new(0, 0, 106, 20); - crate::ui::compute_view(&mut app.state, area); - - let info = app.state.view.pane_infos[0].clone(); - let pane_id = info.id; - 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[?1000h\x1b[?1006h", - 4, - ); - app.state.insert_test_runtime(pane_id, runtime); - crate::ui::compute_view(&mut app.state, area); - - let first_tab = app.state.view.tab_hit_areas[0]; - let pane_col = info.inner_rect.x; - let pane_row = info.inner_rect.y; - app.handle_mouse_from_input_source( - 41, - mouse( - MouseEventKind::Down(MouseButton::Left), - first_tab.x + 1, - first_tab.y, - ), - ); - app.handle_mouse_from_input_source( - 42, - mouse(MouseEventKind::Down(MouseButton::Left), pane_col, pane_row), - ); - app.handle_mouse_from_input_source( - 42, - mouse(MouseEventKind::Up(MouseButton::Left), pane_col, pane_row), - ); - - assert_eq!( - input_rx.try_recv().expect("other source mouse down"), - Bytes::from_static(b"\x1b[<0;1;1M") - ); - assert_eq!( - input_rx.try_recv().expect("other source mouse up"), - Bytes::from_static(b"\x1b[<0;1;1m") - ); - } - - #[test] - fn other_input_source_cannot_release_tab_reorder() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - ws.test_add_tab(Some("second")); - ws.test_add_tab(Some("third")); - 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 source = app.state.view.tab_hit_areas[0]; - let target = app.state.view.tab_hit_areas[2]; - let drop_col = target.x + target.width; - app.handle_mouse_from_input_source( - 41, - mouse( - MouseEventKind::Down(MouseButton::Left), - source.x + 1, - source.y, - ), - ); - app.handle_mouse_from_input_source( - 41, - mouse(MouseEventKind::Drag(MouseButton::Left), drop_col, source.y), - ); - app.handle_mouse_from_input_source( - 42, - mouse(MouseEventKind::Up(MouseButton::Left), drop_col, source.y), - ); - - assert!(app.state.drag.is_some()); - assert_eq!(app.state.workspaces[0].tabs[0].custom_name, None); - - app.handle_mouse_from_input_source( - 41, - mouse(MouseEventKind::Up(MouseButton::Left), drop_col, source.y), - ); - - assert!(app.state.drag.is_none()); - assert_eq!(app.state.workspaces[0].tabs[2].custom_name.as_deref(), None); - } - - #[tokio::test] - async fn tab_click_survives_stray_drag_report_into_a_mouse_reporting_pane() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - ws.test_add_tab(None); - ws.active_tab = 1; - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - let area = Rect::new(0, 0, 106, 20); - crate::ui::compute_view(&mut app.state, area); - - let info = app - .state - .view - .pane_infos - .first() - .cloned() - .expect("visible pane"); - app.state.insert_test_runtime( - info.id, - crate::terminal::TerminalRuntime::test_with_screen_bytes( - info.inner_rect.width.max(1), - info.inner_rect.height.max(1), - b"\x1b[?1002h", - ), - ); - crate::ui::compute_view(&mut app.state, area); - - let first_tab = app.state.view.tab_hit_areas[0]; - let press_col = first_tab.x + 1; - let stray_row = info.inner_rect.bottom().saturating_sub(1); - assert!( - app.state.pane_mouse_target(press_col, stray_row).is_some(), - "stray coordinates must land on the pane for this to test anything" - ); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - press_col, - first_tab.y, - )); - app.handle_mouse(mouse( - MouseEventKind::Drag(MouseButton::Left), - press_col, - stray_row, - )); - app.handle_mouse(mouse( - MouseEventKind::Up(MouseButton::Left), - press_col, - stray_row, - )); - - assert_eq!(app.state.workspaces[0].active_tab, 0); - } - - fn mark_worktree_space_member(workspace: &mut Workspace, ws_idx: usize, key: &str) { - workspace.worktree_space = Some(crate::workspace::WorktreeSpaceMembership { - key: key.into(), - label: "herdr".into(), - repo_root: "/repo/herdr".into(), - checkout_path: format!("/repo/worktree-{ws_idx}").into(), - is_linked_worktree: ws_idx != 0, - }); - } - - #[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 mouse_dispatcher_forwards_horizontal_wheel_to_mouse_reporting_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[?1000h\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; - assert!( - app.state.mouse_capture, - "reproduction must use the default Herdr mouse dispatcher" - ); - - let outer_column = info.inner_rect.x + 2; - let outer_row = info.inner_rect.y + 3; - for (button, expected_kind, ingress) in [ - (66, MouseEventKind::ScrollLeft, "monolithic"), - (67, MouseEventKind::ScrollRight, "headless"), - ] { - let input = format!("\x1b[<{button};{};{}M", outer_column + 1, outer_row + 1); - let mut events = crate::raw_input::parse_raw_input_bytes_sync(input.as_bytes()); - let event = events - .pop() - .expect("horizontal SGR wheel input should parse"); - let crate::raw_input::RawInputEvent::Mouse(mouse) = &event else { - panic!("expected parsed mouse event"); - }; - assert!(events.is_empty(), "expected one parsed mouse event"); - assert_eq!(mouse.kind, expected_kind); - - if ingress == "monolithic" { - assert!(app.handle_raw_input_event(event).await); - } else { - app.route_client_events(vec![event], false); - } - - assert_eq!( - input_rx - .try_recv() - .expect("horizontal wheel should reach pane"), - Bytes::from(format!("\x1b[<{button};3;4M")) - ); - } - assert!(input_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn horizontal_wheel_stays_inert_for_non_mouse_reporting_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"", - 1, - ); - 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; - - let input = format!( - "\x1b[<66;{};{}M", - info.inner_rect.x + 3, - info.inner_rect.y + 4 - ); - let event = crate::raw_input::parse_raw_input_bytes_sync(input.as_bytes()) - .pop() - .expect("horizontal SGR wheel input should parse"); - - assert!(app.handle_raw_input_event(event).await); - - assert!(input_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn pane_right_click_passthrough_is_isolated() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let passthrough_pane = ws.tabs[0].root_pane; - let default_pane = ws.test_split(Direction::Horizontal); - ws.pane_state_mut(passthrough_pane) - .unwrap() - .right_click_passthrough = true; - 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 passthrough_info = app.state.pane_info_by_id(passthrough_pane).unwrap().clone(); - let default_info = app.state.pane_info_by_id(default_pane).unwrap().clone(); - let (passthrough_runtime, mut passthrough_input) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - passthrough_info.inner_rect.width, - passthrough_info.inner_rect.height, - 0, - b"\x1b[?1002h\x1b[?1006h", - 4, - ); - let (default_runtime, mut default_input) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - default_info.inner_rect.width, - default_info.inner_rect.height, - 0, - b"\x1b[?1002h\x1b[?1006h", - 4, - ); - app.state - .insert_test_runtime(passthrough_pane, passthrough_runtime); - app.state.insert_test_runtime(default_pane, default_runtime); - - let col = passthrough_info.inner_rect.x + 2; - let row = passthrough_info.inner_rect.y + 3; - app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Right), col, row)); - - assert_eq!(app.state.mode, Mode::Terminal); - assert!(app.state.context_menu.is_none()); - assert_eq!( - passthrough_input.try_recv().unwrap(), - Bytes::from_static(b"\x1b[<2;3;4M") - ); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Right), - default_info.inner_rect.x + 2, - default_info.inner_rect.y + 3, - )); - - assert!(default_input.try_recv().is_err()); - assert!(matches!( - app.state.context_menu.as_ref().map(|menu| &menu.kind), - Some(ContextMenuKind::Pane { pane_id, .. }) if *pane_id == default_pane - )); - } - - #[tokio::test] - async fn pane_right_click_passthrough_falls_back_when_mouse_reporting_is_off() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - ws.pane_state_mut(pane_id).unwrap().right_click_passthrough = true; - 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.pane_info_by_id(pane_id).unwrap().clone(); - app.state.insert_test_runtime( - pane_id, - crate::terminal::TerminalRuntime::test_with_screen_bytes( - info.inner_rect.width, - info.inner_rect.height, - b"", - ), - ); - - app.handle_mouse(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()); - } - - #[tokio::test] - async fn configured_right_click_passthrough_forwards_gesture_outside_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), 0, 0) - }); - app.handle_mouse(MouseEvent { - modifiers: KeyModifiers::CONTROL, - ..mouse(MouseEventKind::Up(MouseButton::Right), 0, 0) - }); - - 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;1;1M") - ); - assert_eq!( - input_rx.try_recv().expect("forwarded right mouse up"), - Bytes::from_static(b"\x1b[<2;1;1m") - ); - assert!(input_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn captured_left_press_focuses_target_before_forwarding() { - 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; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let info = app - .state - .pane_info_by_id(target) - .expect("target 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(target, runtime); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - info.inner_rect.x + 1, - info.inner_rect.y + 1, - )); - - assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(target)); - assert_eq!( - input_rx.try_recv().expect("forwarded captured left press"), - Bytes::from_static(b"\x1b[<0;2;2M") - ); - } - - #[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 ordinary_cell_mouse_downgrades_pixel_mode_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; - app.state.host_cell_size = crate::kitty_graphics::HostCellSize { - width_px: 10, - height_px: 20, - }; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let info = app.state.view.pane_infos[0].clone(); - app.state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) - .unwrap() - .resize(info.inner_rect.height, info.inner_rect.width, 10, 20); - 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 dedicated_client_pixel_mouse_preserves_subcell_position() { - 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; - app.state.mouse_capture = false; - app.state.host_cell_size = crate::kitty_graphics::HostCellSize { - width_px: 10, - height_px: 20, - }; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let inner = app.state.view.pane_infos[0].inner_rect; - let geometry = crate::input::mouse::HostGeometry::new(106, 20, 1_060, 400).unwrap(); - let x = u32::from(inner.x + 2) * 10 + 8; - let y = u32::from(inner.y + 3) * 20 + 9; - let report = format!("\x1b[<35;{x};{y}M"); - app.state.host_mouse_pixels = Some(crate::input::mouse::HostPixels { x, y, geometry }); - let runtime = app - .state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) - .unwrap(); - assert_eq!(runtime.pixel_size(), None); - assert_eq!( - app.state.pane_mouse_position( - runtime, - inner, - mouse(MouseEventKind::Moved, inner.x + 2, inner.y + 3), - ), - Some(crate::input::mouse::Position::Cell { column: 2, row: 3 }) - ); - runtime.resize(inner.height, inner.width, 10, 20); - let runtime = app - .state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) - .unwrap(); - assert_eq!( - runtime.pixel_size(), - Some((u32::from(inner.width) * 10, u32::from(inner.height) * 20)) - ); - assert_eq!( - app.state.pane_mouse_position( - runtime, - inner, - mouse(MouseEventKind::Moved, inner.x + 2, inner.y + 3), - ), - Some(crate::input::mouse::Position::Pixels { x: 28, y: 69 }) - ); - app.state.host_mouse_pixels = None; - - assert!(app.route_client_pixel_mouse(7, report.as_bytes(), geometry)); - assert_eq!( - input_rx.try_recv().expect("forwarded exact mouse motion"), - Bytes::from_static(b"\x1b[<35;28;69M") - ); - assert!(input_rx.try_recv().is_err()); - assert!(app.state.host_mouse_pixels.is_none()); - } - - #[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 normal_right_click_keeps_focus_and_exposes_swap_for_reporting_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; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 100, 20)); - let target_info = app - .state - .pane_info_by_id(target) - .expect("target pane info") - .clone(); - let (runtime, mut input_rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - target_info.inner_rect.width, - target_info.inner_rect.height, - 0, - b"\x1b[?1002h\x1b[?1006h", - 4, - ); - app.state.insert_test_runtime(target, runtime); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Right), - target_info.inner_rect.x, - target_info.inner_rect.y, - )); - - assert!(input_rx.try_recv().is_err()); - 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 - )); - assert!(menu.items().contains(&"Swap with focused pane")); - } - - #[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.begin_workspace_close_confirmation(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, - 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, - app.state.host_terminal_appearance, - 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, - right_click_passthrough: 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, - color_scheme_reporting: 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 bottom_mode_bar_consumes_hidden_tab_mouse_actions() { - 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::Prefix; - app.state.tab_bar_position = crate::config::TabBarPositionConfig::Bottom; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let second_tab = app.state.view.tab_hit_areas[1]; - let new_tab = app.state.view.new_tab_hit_area; - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - second_tab.x, - second_tab.y, - )); - app.handle_mouse(mouse( - MouseEventKind::Up(MouseButton::Left), - second_tab.x, - second_tab.y, - )); - app.handle_mouse(mouse( - MouseEventKind::ScrollDown, - second_tab.x, - second_tab.y, - )); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Right), - second_tab.x, - second_tab.y, - )); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - new_tab.x, - new_tab.y, - )); - - app.state.drag = Some(DragState { - target: DragTarget::SidebarDivider, - }); - app.handle_mouse(mouse( - MouseEventKind::Up(MouseButton::Left), - second_tab.x, - second_tab.y, - )); - - assert_eq!(app.state.workspaces[0].active_tab, 0); - assert_eq!(app.state.workspaces[0].tabs.len(), 2); - assert!(app.state.context_menu.is_none()); - assert!(app.state.tab_presses.is_empty()); - assert!(app.state.drag.is_none()); - } - - #[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 clicking_tab_context_menu_close_leaves_context_menu_mode() { - 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, - )); - - let menu = app - .state - .context_menu_rect() - .expect("tab context menu rect"); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - menu.x + 2, - menu.y + 3, - )); - - assert_eq!(app.state.workspaces[0].tabs.len(), 1); - assert_eq!(app.state.workspaces[0].display_name(), "one"); - assert!(app.state.context_menu.is_none()); - assert_eq!(app.state.mode, Mode::Terminal); - assert!(app - .event_hub - .events_after(0) - .iter() - .any(|(_, event)| { matches!(event.event, crate::api::schema::EventKind::TabClosed) })); - } - - #[test] - fn clicking_pane_context_menu_close_leaves_context_menu_mode() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("one"); - let first_pane = ws.tabs[0].root_pane; - let second_pane = ws.test_split(Direction::Horizontal); - ws.tabs[0].layout.focus_pane(second_pane); - 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 first_info = app - .state - .view - .pane_infos - .iter() - .find(|info| info.id == first_pane) - .expect("first pane info") - .clone(); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Right), - first_info.inner_rect.x + 1, - first_info.inner_rect.y + 1, - )); - - let menu_state = app.state.context_menu.as_ref().expect("pane context menu"); - let close_idx = menu_state - .items() - .iter() - .position(|item| *item == "Close pane") - .expect("close pane menu item"); - let menu = app - .state - .context_menu_rect() - .expect("pane context menu rect"); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - menu.x + 2, - menu.y + 1 + close_idx as u16, - )); - - assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 1); - assert!(app.state.context_menu.is_none()); - assert_eq!(app.state.mode, Mode::Terminal); - assert!(app.event_hub.events_after(0).iter().any(|(_, event)| { - matches!(event.event, crate::api::schema::EventKind::PaneClosed) - })); - } - - #[test] - fn clicking_pane_context_menu_close_last_parent_group_pane_keeps_confirmation_mode() { - let mut app = app_for_mouse_test(); - let mut parent = Workspace::test_new("main"); - let pane_id = parent.tabs[0].root_pane; - mark_worktree_space_member(&mut parent, 0, "repo-key"); - let mut child = Workspace::test_new("issue"); - mark_worktree_space_member(&mut child, 1, "repo-key"); - app.state.workspaces = vec![parent, child]; - app.state.active = Some(0); - app.state.selected = 1; - app.state.mode = Mode::Terminal; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let pane_info = app - .state - .view - .pane_infos - .iter() - .find(|info| info.id == pane_id) - .expect("pane info") - .clone(); - - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Right), - pane_info.inner_rect.x + 1, - pane_info.inner_rect.y + 1, - )); - - let menu_state = app.state.context_menu.as_ref().expect("pane context menu"); - let close_idx = menu_state - .items() - .iter() - .position(|item| *item == "Close pane") - .expect("close pane menu item"); - let menu = app - .state - .context_menu_rect() - .expect("pane context menu rect"); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - menu.x + 2, - menu.y + 1 + close_idx as u16, - )); - - assert_eq!(app.state.selected, 0); - assert_eq!(app.state.mode, Mode::ConfirmClose); - assert_eq!(app.state.workspaces.len(), 2); - assert!(app.state.context_menu.is_none()); - } - - #[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_new_workspace_opens_prompt_when_enabled() { - 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_workspace_name = true; - - 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_eq!(app.state.mode, Mode::RenameWorkspace); - assert!(app.state.pending_workspace_create_cwd.is_some()); - assert!(app.state.name_input_replace_on_type); - assert_eq!(app.state.workspaces.len(), 1); - } - - #[test] - fn desktop_new_workspace_opens_prompt_when_enabled() { - 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_workspace_name = true; - - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 120, 40)); - let new_workspace = app.state.sidebar_new_button_rect(); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - new_workspace.x + 1, - new_workspace.y, - )); - - assert_eq!(app.state.mode, Mode::RenameWorkspace); - assert!(app.state.pending_workspace_create_cwd.is_some()); - assert!(app.state.name_input_replace_on_type); - assert_eq!(app.state.workspaces.len(), 1); - } - - #[tokio::test] - async fn desktop_new_workspace_creates_immediately_by_default() { - let mut app = app_for_mouse_test(); - app.state.workspaces = vec![Workspace::test_new("one")]; - app.state.ensure_test_terminals(); - 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, 120, 40)); - let new_workspace = app.state.sidebar_new_button_rect(); - app.handle_mouse(mouse( - MouseEventKind::Down(MouseButton::Left), - new_workspace.x + 1, - new_workspace.y, - )); - - assert_eq!(app.state.workspaces.len(), 2); - assert_eq!(app.state.mode, Mode::Terminal); - assert!(app.state.pending_workspace_create_cwd.is_none()); - crate::app::api::test_support::shutdown_test_runtimes(&mut app); - } - - #[test] - fn mobile_switcher_new_tab_opens_dialog_when_enabled() { - 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 + 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, - color_scheme_reporting: 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, - color_scheme_reporting: false, - }; - - assert_eq!(wheel_routing(input_state), WheelRouting::HostScroll); - } -} |
