diff options
Diffstat (limited to 'src/client/shell/mouse.rs')
| -rw-r--r-- | src/client/shell/mouse.rs | 2304 |
1 files changed, 2304 insertions, 0 deletions
diff --git a/src/client/shell/mouse.rs b/src/client/shell/mouse.rs new file mode 100644 index 00000000..437ec4ed --- /dev/null +++ b/src/client/shell/mouse.rs @@ -0,0 +1,2304 @@ +use super::*; +use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; + +const SELECTION_AUTOSCROLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(30); + +impl ClientShellState { + fn set_sidebar_width_from_column(&mut self, column: u16, outcome: &mut ClientShellInput) { + let (min, max) = crate::config::validated_sidebar_bounds( + self.config.sidebar_min_width, + self.config.sidebar_max_width, + ) + .unwrap_or((18, 36)); + let width = column.saturating_add(1).clamp(min, max); + if self.sidebar_width != width { + self.sidebar_width = width; + self.sidebar_width_manual = true; + self.invalidate_pane_surface(); + outcome.repaint = true; + outcome.resize = true; + } + } + + fn set_sidebar_section_from_row(&mut self, row: u16, outcome: &mut ClientShellInput) { + let divider = self.hits.sidebar_divider; + if divider.height == 0 { + return; + } + let ratio = row.saturating_sub(divider.y) as f32 / divider.height as f32; + let ratio = ratio.clamp(0.1, 0.9); + if (self.sidebar_section_split - ratio).abs() > f32::EPSILON { + self.sidebar_section_split = ratio; + self.sidebar_section_split_manual = true; + outcome.repaint = true; + } + } + + fn pane_scrollbar_offset( + hit: &PaneHit, + row: u16, + grab_row_offset: Option<u16>, + ) -> Option<usize> { + let track = hit.scrollbar_rect?; + let metrics = hit.scroll?; + (metrics.max_offset_from_bottom > 0).then(|| match grab_row_offset { + Some(grab_row_offset) => { + crate::ui::scrollbar_offset_from_drag_row(metrics, track, row, grab_row_offset) + } + None => crate::ui::scrollbar_offset_from_row(metrics, track, row), + }) + } + + pub(super) fn push_pane_scroll_offset( + &mut self, + pane_id: String, + offset_from_bottom: usize, + outcome: &mut ClientShellInput, + ) { + self.pane_scroll_targets + .insert(pane_id.clone(), offset_from_bottom); + if self.pane_scroll_in_flight.contains_key(&pane_id) { + self.pane_scroll_queued.insert(pane_id, offset_from_bottom); + return; + } + self.dispatch_pane_scroll_offset(pane_id, offset_from_bottom, outcome); + } + + fn dispatch_pane_scroll_offset( + &mut self, + pane_id: String, + offset_from_bottom: usize, + outcome: &mut ClientShellInput, + ) { + if self.snapshot.is_none() { + return; + } + self.next_scroll_serial = self.next_scroll_serial.saturating_add(1); + let serial = self.next_scroll_serial; + self.pane_scroll_targets + .insert(pane_id.clone(), offset_from_bottom); + self.pane_scroll_in_flight.insert(pane_id.clone(), serial); + self.push_endpoint_method_with_kind( + crate::api::schema::Method::PaneScroll(crate::api::schema::PaneScrollParams { + pane_id: pane_id.clone(), + offset_from_bottom: offset_from_bottom as u64, + }), + PendingEndpointKind::PaneScroll { pane_id, serial }, + outcome, + ); + } + + pub(super) fn complete_pane_scroll( + &mut self, + pane_id: String, + serial: u64, + result: Result<crate::api::schema::ResponseResult, ClientShellEndpointError>, + outcome: &mut ClientShellInput, + ) -> bool { + if self.pane_scroll_in_flight.get(&pane_id).copied() != Some(serial) { + return false; + } + self.pane_scroll_in_flight.remove(&pane_id); + let repaint = match result { + Ok(crate::api::schema::ResponseResult::PaneInfo { pane }) + if pane.pane_id == pane_id => + { + if let Some(scroll) = pane.scroll { + if self.pane_scroll_targets.contains_key(&pane_id) { + self.pane_scroll_targets.insert( + pane_id.clone(), + usize::try_from(scroll.offset_from_bottom).unwrap_or(usize::MAX), + ); + } + } + false + } + Ok(_) => { + self.pane_scroll_queued.remove(&pane_id); + self.pane_scroll_targets.remove(&pane_id); + self.endpoint_error = + Some("endpoint returned an unexpected pane-scroll result".to_owned()); + true + } + Err(error) => { + self.pane_scroll_queued.remove(&pane_id); + self.pane_scroll_targets.remove(&pane_id); + self.endpoint_error = Some(error.message); + true + } + }; + if let Some(offset) = self.pane_scroll_queued.remove(&pane_id) { + self.dispatch_pane_scroll_offset(pane_id, offset, outcome); + } + repaint + } + + pub(super) fn stop_selection_autoscroll(&mut self) { + self.selection_autoscroll = None; + self.selection_autoscroll_deadline = None; + } + + fn selection_edge_scroll_lines(distance: u16) -> usize { + usize::from(distance).saturating_mul(3).clamp(3, 15) + } + + fn selection_scroll_metrics(&self, hit: &PaneHit) -> Option<crate::pane::ScrollMetrics> { + let metrics = hit.scroll?; + Some( + self.selection_autoscroll + .as_ref() + .filter(|autoscroll| autoscroll.pane_id == hit.pane_id) + .map_or(metrics, |autoscroll| crate::pane::ScrollMetrics { + offset_from_bottom: autoscroll.offset_from_bottom, + max_offset_from_bottom: autoscroll.max_offset_from_bottom, + viewport_rows: metrics.viewport_rows, + }), + ) + } + + fn update_selection_cursor_with_metrics( + &mut self, + hit: &PaneHit, + column: u16, + row: u16, + metrics: Option<crate::pane::ScrollMetrics>, + ) { + if let Some(selection) = self.selection.as_mut() { + selection.drag(column, row, hit.inner_rect, metrics); + } + } + + fn update_selection_drag( + &mut self, + hit: &PaneHit, + column: u16, + row: u16, + outcome: &mut ClientShellInput, + ) { + let metrics = self.selection_scroll_metrics(hit); + let was_dragging = self + .selection + .as_ref() + .is_some_and(crate::selection::Selection::is_dragging); + let moved_from_anchor = self.selection.as_ref().is_some_and(|selection| { + let (anchor_row, anchor_col) = selection.anchor_screen_pos(hit.inner_rect, metrics); + anchor_row != row || anchor_col != column + }); + let is_dragging = was_dragging || moved_from_anchor; + self.update_selection_cursor_with_metrics(hit, column, row, metrics); + if is_dragging { + if let Some(selection) = self.selection.as_mut() { + if selection.is_just_click() { + selection.force_dragging(); + } + } + self.last_pane_click = None; + } + if !is_dragging { + self.stop_selection_autoscroll(); + return; + } + + let Some(metrics) = metrics else { + self.stop_selection_autoscroll(); + return; + }; + let top = hit.inner_rect.y; + let bottom = hit.inner_rect.y + hit.inner_rect.height.saturating_sub(1); + let (direction, immediate_lines) = if row < top { + ( + ClientSelectionAutoscrollDirection::Up, + Self::selection_edge_scroll_lines(top - row), + ) + } else if row > bottom { + ( + ClientSelectionAutoscrollDirection::Down, + Self::selection_edge_scroll_lines(row - bottom), + ) + } else if row == top { + (ClientSelectionAutoscrollDirection::Up, 0) + } else if row == bottom { + (ClientSelectionAutoscrollDirection::Down, 0) + } else { + self.stop_selection_autoscroll(); + return; + }; + + let offset_from_bottom = match direction { + ClientSelectionAutoscrollDirection::Up => metrics + .offset_from_bottom + .saturating_add(immediate_lines) + .min(metrics.max_offset_from_bottom), + ClientSelectionAutoscrollDirection::Down => { + metrics.offset_from_bottom.saturating_sub(immediate_lines) + } + }; + if offset_from_bottom != metrics.offset_from_bottom { + let projected = crate::pane::ScrollMetrics { + offset_from_bottom, + ..metrics + }; + self.update_selection_cursor_with_metrics(hit, column, row, Some(projected)); + self.push_pane_scroll_offset(hit.pane_id.clone(), offset_from_bottom, outcome); + } + self.selection_autoscroll = Some(ClientSelectionAutoscroll { + pane_id: hit.pane_id.clone(), + direction, + last_mouse_column: column, + last_mouse_row: row, + inner_rect: hit.inner_rect, + offset_from_bottom, + max_offset_from_bottom: metrics.max_offset_from_bottom, + }); + self.selection_autoscroll_deadline = + Some(std::time::Instant::now() + SELECTION_AUTOSCROLL_INTERVAL); + } + + fn scroll_in_progress_selection( + &mut self, + mouse: MouseEvent, + outcome: &mut ClientShellInput, + ) -> bool { + if !matches!( + mouse.kind, + MouseEventKind::ScrollUp | MouseEventKind::ScrollDown + ) || !self + .selection + .as_ref() + .is_some_and(crate::selection::Selection::is_in_progress) + { + return false; + } + let Some(hit) = self.selection.as_ref().and_then(|selection| { + self.hits + .panes + .iter() + .find(|hit| hit.pane_id == selection.pane_id) + .cloned() + }) else { + return false; + }; + let Some(metrics) = self.selection_scroll_metrics(&hit) else { + return false; + }; + let offset_from_bottom = match mouse.kind { + MouseEventKind::ScrollUp => metrics + .offset_from_bottom + .saturating_add(self.config.mouse_scroll_lines) + .min(metrics.max_offset_from_bottom), + MouseEventKind::ScrollDown => metrics + .offset_from_bottom + .saturating_sub(self.config.mouse_scroll_lines), + _ => unreachable!(), + }; + if offset_from_bottom != metrics.offset_from_bottom { + let projected = crate::pane::ScrollMetrics { + offset_from_bottom, + ..metrics + }; + self.update_selection_cursor_with_metrics( + &hit, + mouse.column, + mouse.row, + Some(projected), + ); + self.push_pane_scroll_offset(hit.pane_id, offset_from_bottom, outcome); + outcome.repaint = true; + } + true + } + + pub(crate) fn tick_selection_autoscroll( + &mut self, + now: std::time::Instant, + ) -> ClientShellInput { + let mut outcome = ClientShellInput::default(); + if self + .selection_autoscroll_deadline + .is_none_or(|deadline| now < deadline) + { + return outcome; + } + let Some(mut autoscroll) = self.selection_autoscroll.clone() else { + self.selection_autoscroll_deadline = None; + return outcome; + }; + if !self.selection.as_ref().is_some_and(|selection| { + selection.pane_id == autoscroll.pane_id && selection.is_dragging() + }) { + self.stop_selection_autoscroll(); + return outcome; + } + let Some(hit) = self + .hits + .panes + .iter() + .find(|hit| hit.pane_id == autoscroll.pane_id) + .cloned() + else { + self.stop_selection_autoscroll(); + return outcome; + }; + if hit.inner_rect != autoscroll.inner_rect { + self.stop_selection_autoscroll(); + return outcome; + } + let next_offset = match autoscroll.direction { + ClientSelectionAutoscrollDirection::Up => autoscroll + .offset_from_bottom + .saturating_add(1) + .min(autoscroll.max_offset_from_bottom), + ClientSelectionAutoscrollDirection::Down => { + autoscroll.offset_from_bottom.saturating_sub(1) + } + }; + if next_offset == autoscroll.offset_from_bottom { + self.stop_selection_autoscroll(); + return outcome; + } + autoscroll.offset_from_bottom = next_offset; + let metrics = crate::pane::ScrollMetrics { + offset_from_bottom: next_offset, + max_offset_from_bottom: autoscroll.max_offset_from_bottom, + viewport_rows: hit.scroll.map_or(0, |metrics| metrics.viewport_rows), + }; + self.update_selection_cursor_with_metrics( + &hit, + autoscroll.last_mouse_column, + autoscroll.last_mouse_row, + Some(metrics), + ); + self.push_pane_scroll_offset(autoscroll.pane_id.clone(), next_offset, &mut outcome); + self.selection_autoscroll = Some(autoscroll); + self.selection_autoscroll_deadline = Some(now + SELECTION_AUTOSCROLL_INTERVAL); + outcome.repaint = true; + outcome + } + + fn pane_split_target_is_current(&self, hit: &PaneSplitHit, tab_id: &str) -> Option<bool> { + let snapshot = self.snapshot.as_deref()?; + let surface = self.pane_surface.as_ref()?; + if snapshot.revision != surface.projection_revision { + return None; + } + Some( + snapshot.focused_tab_id.as_deref() == Some(tab_id) + && pane_surface_topology_signature(surface) == hit.topology_signature, + ) + } + + fn pane_split_ratio(hit: &PaneSplitHit, grab_offset: i32, point: (u16, u16)) -> f32 { + let (pointer, origin, length) = match hit.direction { + crate::protocol::PaneSurfaceSplitDirection::Horizontal => { + (i32::from(point.0), i32::from(hit.area.x), hit.area.width) + } + crate::protocol::PaneSurfaceSplitDirection::Vertical => { + (i32::from(point.1), i32::from(hit.area.y), hit.area.height) + } + }; + ((pointer + grab_offset - origin) as f32 / f32::from(length.max(1))).clamp(0.1, 0.9) + } + + fn tab_drop_index_at(&self, point: (u16, u16)) -> Option<usize> { + let snapshot = self.snapshot.as_deref()?; + let workspace_id = snapshot.focused_workspace_id.as_deref()?; + let tabs = snapshot + .tabs + .iter() + .filter(|tab| tab.workspace_id == workspace_id) + .collect::<Vec<_>>(); + let visible = self + .hits + .tabs + .iter() + .filter_map(|(rect, tab_id)| { + tabs.iter() + .position(|tab| tab.tab_id == *tab_id) + .map(|index| (index, *rect)) + }) + .collect::<Vec<_>>(); + let (first_index, first_rect) = *visible.first()?; + let (last_index, last_rect) = *visible.last()?; + let on_tab_row = point.1 == first_rect.y; + if !on_tab_row { + return None; + } + if super::contains(self.hits.tab_scroll_left, point) { + return Some(0); + } + if super::contains(self.hits.tab_scroll_right, point) { + return Some(tabs.len()); + } + let left_edge = if first_index == 0 { + first_rect.x + } else { + self.hits.tab_scroll_left.right() + }; + let right_edge = if last_index + 1 >= tabs.len() { + last_rect.right() + } else { + self.hits.tab_scroll_right.x.saturating_sub(1) + }; + if point.0 <= left_edge { + return Some(first_index); + } + if point.0 >= right_edge { + return Some(last_index + 1); + } + for (index, rect) in visible { + let midpoint = rect.x + rect.width / 2; + if point.0 < midpoint { + return Some(index); + } + if point.0 < rect.right() { + return Some(index + 1); + } + } + Some(last_index + 1) + } + + fn workspace_drop_target_at(&self, point: (u16, u16)) -> Option<(Option<String>, u16)> { + if self.hits.workspace_body.height == 0 + || point.1 < self.hits.workspace_body.y.saturating_sub(1) + || point.1 >= self.hits.new_workspace.y + { + return None; + } + let mut slots = self + .hits + .workspaces + .iter() + .filter(|hit| !hit.indented) + .map(|hit| (Some(hit.workspace_id.clone()), hit.rect.y.saturating_sub(1))) + .collect::<Vec<_>>(); + let snapshot = self.snapshot.as_deref()?; + let entries = render::workspace_entries(snapshot, &self.collapsed_groups); + let last_hit = self.hits.workspaces.last()?; + let last_position = entries.iter().position(|entry| { + snapshot + .workspaces + .get(entry.index) + .is_some_and(|workspace| workspace.workspace_id == last_hit.workspace_id) + })?; + let next = entries.get(last_position + 1); + if !next.is_some_and(|entry| entry.indented) { + let before = next.and_then(|entry| { + snapshot + .workspaces + .get(entry.index) + .map(|workspace| workspace.workspace_id.clone()) + }); + let row = last_hit.rect.bottom(); + if row < self.hits.new_workspace.y { + slots.push((before, row)); + } + } + slots + .into_iter() + .enumerate() + .min_by_key(|(index, (_, row))| (point.1.abs_diff(*row), *index)) + .map(|(_, target)| target) + } + + fn workspace_move_method( + &self, + source_workspace_id: &str, + before_workspace_id: Option<&str>, + ) -> Option<crate::api::schema::Method> { + let snapshot = self.snapshot.as_deref()?; + let source = snapshot + .workspaces + .iter() + .find(|workspace| workspace.workspace_id == source_workspace_id)?; + if source + .worktree + .as_ref() + .is_some_and(|worktree| worktree.is_linked_worktree) + { + return None; + } + if before_workspace_id == Some(source_workspace_id) { + return None; + } + let roots = snapshot + .workspaces + .iter() + .filter(|workspace| { + !workspace + .worktree + .as_ref() + .is_some_and(|worktree| worktree.is_linked_worktree) + }) + .collect::<Vec<_>>(); + let source_position = roots + .iter() + .position(|workspace| workspace.workspace_id == source_workspace_id)?; + let remaining = roots + .iter() + .copied() + .filter(|workspace| workspace.workspace_id != source_workspace_id) + .collect::<Vec<_>>(); + let insert_position = match before_workspace_id { + Some(target) => remaining + .iter() + .position(|workspace| workspace.workspace_id == target)?, + None => remaining.len(), + }; + if insert_position == source_position { + return None; + } + + if let Some(worktree) = source.worktree.as_ref() { + let workspace_ids = std::iter::once(source.workspace_id.clone()) + .chain( + snapshot + .workspaces + .iter() + .filter(|workspace| workspace.workspace_id != source.workspace_id) + .filter(|workspace| { + workspace + .worktree + .as_ref() + .is_some_and(|candidate| candidate.key == worktree.key) + }) + .map(|workspace| workspace.workspace_id.clone()), + ) + .collect(); + Some(crate::api::schema::Method::WorkspaceMoveBlock( + crate::api::schema::WorkspaceMoveBlockParams { + workspace_ids, + before_workspace_id: before_workspace_id.map(str::to_owned), + }, + )) + } else { + let insert_index = before_workspace_id + .and_then(|target| { + snapshot + .workspaces + .iter() + .position(|workspace| workspace.workspace_id == target) + }) + .unwrap_or(snapshot.workspaces.len()); + Some(crate::api::schema::Method::WorkspaceMove( + crate::api::schema::WorkspaceMoveParams { + workspace_id: source.workspace_id.clone(), + insert_index, + }, + )) + } + } + + pub(super) fn handle_mouse(&mut self, mouse: MouseEvent, outcome: &mut ClientShellInput) { + let point = (mouse.column, mouse.row); + if matches!(self.overlay, Some(ClientShellOverlay::Onboarding)) { + if mouse.kind == MouseEventKind::Down(MouseButton::Left) + && super::contains(self.hits.overlay_primary, point) + { + self.complete_onboarding(outcome); + } + return; + } + if matches!( + self.overlay, + Some(ClientShellOverlay::ProductAnnouncement(_)) + ) { + match mouse.kind { + MouseEventKind::Down(MouseButton::Left) + if super::contains(self.hits.overlay_primary, point) => + { + self.dismiss_product_announcement(outcome); + } + MouseEventKind::Down(MouseButton::Left) + if super::contains(self.hits.product_announcement_scrollbar, point) => + { + if let Some(metrics) = self.hits.product_announcement_scroll_metrics { + if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset( + metrics, + self.hits.product_announcement_scrollbar, + mouse.row, + ) { + self.chrome_drag = + Some(ClientChromeDrag::ProductAnnouncementScrollbar { + grab_row_offset, + }); + } else { + let offset = crate::ui::scrollbar_offset_from_row( + metrics, + self.hits.product_announcement_scrollbar, + mouse.row, + ); + self.set_product_announcement_offset_from_bottom(offset); + outcome.repaint = true; + } + } + } + MouseEventKind::Drag(MouseButton::Left) => { + if let ( + Some(ClientChromeDrag::ProductAnnouncementScrollbar { grab_row_offset }), + Some(metrics), + ) = ( + self.chrome_drag.as_ref(), + self.hits.product_announcement_scroll_metrics, + ) { + let offset = crate::ui::scrollbar_offset_from_drag_row( + metrics, + self.hits.product_announcement_scrollbar, + mouse.row, + *grab_row_offset, + ); + self.set_product_announcement_offset_from_bottom(offset); + outcome.repaint = true; + } + } + MouseEventKind::Up(MouseButton::Left) => { + self.chrome_drag = None; + } + MouseEventKind::ScrollUp => { + self.scroll_product_announcement(-3); + outcome.repaint = true; + } + MouseEventKind::ScrollDown => { + self.scroll_product_announcement(3); + outcome.repaint = true; + } + _ => {} + } + return; + } + if matches!(self.overlay, Some(ClientShellOverlay::ReleaseNotes(_))) { + let (close, track, metrics) = self + .current_release_notes_input_geometry() + .map(|(close, track, metrics)| (close, track, Some(metrics))) + .unwrap_or(( + self.hits.overlay_primary, + (!self.hits.release_notes_scrollbar.is_empty()) + .then_some(self.hits.release_notes_scrollbar), + self.hits.release_notes_scroll_metrics, + )); + match mouse.kind { + MouseEventKind::Down(MouseButton::Left) if super::contains(close, point) => { + self.dismiss_release_notes(outcome); + } + MouseEventKind::Down(MouseButton::Left) + if track.is_some_and(|track| super::contains(track, point)) => + { + if let (Some(track), Some(metrics)) = (track, metrics) { + if let Some(grab_row_offset) = + crate::ui::scrollbar_thumb_grab_offset(metrics, track, mouse.row) + { + self.chrome_drag = + Some(ClientChromeDrag::ReleaseNotesScrollbar { grab_row_offset }); + } else { + let offset = + crate::ui::scrollbar_offset_from_row(metrics, track, mouse.row); + self.set_release_notes_offset_from_bottom(offset); + outcome.repaint = true; + } + } + } + MouseEventKind::Drag(MouseButton::Left) => { + if let ( + Some(ClientChromeDrag::ReleaseNotesScrollbar { grab_row_offset }), + Some(track), + Some(metrics), + ) = (self.chrome_drag.as_ref(), track, metrics) + { + let offset = crate::ui::scrollbar_offset_from_drag_row( + metrics, + track, + mouse.row, + *grab_row_offset, + ); + self.set_release_notes_offset_from_bottom(offset); + outcome.repaint = true; + } + } + MouseEventKind::Up(MouseButton::Left) => { + self.chrome_drag = None; + } + MouseEventKind::ScrollUp => { + self.scroll_release_notes(-3); + outcome.repaint = true; + } + MouseEventKind::ScrollDown => { + self.scroll_release_notes(3); + outcome.repaint = true; + } + _ => {} + } + return; + } + if self.url_click_consumes_until_up { + match mouse.kind { + MouseEventKind::Drag(MouseButton::Left) => return, + MouseEventKind::Up(MouseButton::Left) => { + self.url_click_consumes_until_up = false; + return; + } + MouseEventKind::Down(MouseButton::Left) => { + self.url_click_consumes_until_up = false; + } + _ => {} + } + } + if !self.replaying_url_click + && matches!( + mouse.kind, + MouseEventKind::Drag(MouseButton::Left) | MouseEventKind::Up(MouseButton::Left) + ) + { + if let Some(fallback_events) = + self.pending_requests + .values_mut() + .find_map(|pending| match &mut pending.kind { + PendingEndpointKind::PaneLinkActivate { + fallback_events, .. + } if !fallback_events + .iter() + .any(|event| event.kind == MouseEventKind::Up(MouseButton::Left)) => + { + Some(fallback_events) + } + _ => None, + }) + { + fallback_events.push(mouse); + return; + } + } + if let Some(gesture) = self.pane_mouse_gesture.as_ref() { + let gesture_event = matches!( + mouse.kind, + MouseEventKind::Drag(button) | MouseEventKind::Up(button) + if button == gesture.button + ); + if gesture_event { + let button = gesture.button; + let modifiers = mouse.modifiers.difference(gesture.stripped_modifiers); + let hit = if gesture.hit.popup { + self.hits + .popup + .as_ref() + .filter(|hit| hit.pane_id == gesture.hit.pane_id) + .cloned() + } else { + self.hits + .panes + .iter() + .find(|hit| hit.pane_id == gesture.hit.pane_id) + .cloned() + } + .unwrap_or_else(|| gesture.hit.clone()); + let position = self.pane_mouse_position(&hit, mouse); + if let Some(gesture) = self.pane_mouse_gesture.as_mut() { + gesture.last_event = mouse; + gesture.last_position = position; + } + self.push_pane_mouse_event(&hit, mouse, modifiers, outcome); + if mouse.kind == MouseEventKind::Up(button) { + self.pane_mouse_gesture = None; + } + return; + } + if matches!( + mouse.kind, + MouseEventKind::Down(_) | MouseEventKind::Drag(_) | MouseEventKind::Up(_) + ) { + return; + } + } + if self.popup_pending { + return; + } + if let Some(hit) = self.hits.popup.clone() { + if super::contains(hit.inner_rect, point) { + match mouse.kind { + MouseEventKind::Down(button) => { + self.push_pane_mouse_event(&hit, mouse, mouse.modifiers, outcome); + if hit.mouse_reporting { + self.pane_mouse_gesture = Some(ClientPaneMouseGesture { + last_position: self.pane_mouse_position(&hit, mouse), + hit, + button, + stripped_modifiers: crossterm::event::KeyModifiers::empty(), + last_event: mouse, + }); + } + } + MouseEventKind::Moved if hit.mouse_reporting => { + self.push_pane_mouse_event(&hit, mouse, mouse.modifiers, outcome); + } + MouseEventKind::ScrollUp + | MouseEventKind::ScrollDown + | MouseEventKind::ScrollLeft + | MouseEventKind::ScrollRight => { + self.push_pane_mouse_event(&hit, mouse, mouse.modifiers, outcome); + } + MouseEventKind::Up(_) | MouseEventKind::Drag(_) | MouseEventKind::Moved => {} + } + } + return; + } + if self.popup_terminal_id.is_some() { + return; + } + if !self.replaying_url_click + && self.overlay.is_none() + && self.mode == ClientShellMode::Terminal + && mouse.kind == MouseEventKind::Down(MouseButton::Left) + && mouse + .modifiers + .contains(crossterm::event::KeyModifiers::CONTROL) + { + if let Some(hit) = self + .hits + .panes + .iter() + .find(|hit| super::contains(hit.inner_rect, point)) + .cloned() + { + let viewport_row = mouse.row.saturating_sub(hit.inner_rect.y); + let col = mouse.column.saturating_sub(hit.inner_rect.x); + let content_revision = self + .pane_surface + .as_ref() + .and_then(|surface| { + surface + .panes + .iter() + .find(|pane| pane.pane_id == hit.pane_id) + }) + .map(|pane| pane.content_revision); + self.last_pane_click = None; + let pane_id = hit.pane_id.clone(); + self.push_endpoint_method_with_kind( + crate::api::schema::Method::PaneLinkActivate( + crate::api::schema::PaneLinkActivateParams { + pane_id: pane_id.clone(), + viewport_row, + col, + content_revision, + offset_from_bottom: hit + .scroll + .map(|metrics| metrics.offset_from_bottom as u64), + }, + ), + PendingEndpointKind::PaneLinkActivate { + pane_id, + inner_rect: hit.inner_rect, + fallback_events: vec![mouse], + }, + outcome, + ); + return; + } + } + if self.overlay.is_none() + && self.mode == ClientShellMode::Terminal + && self + .visible_notification + .as_ref() + .is_some_and(|notification| notification.event.pane_id.is_some()) + && mouse.kind == MouseEventKind::Down(MouseButton::Left) + && super::contains(self.hits.notification_toast, point) + { + self.focus_visible_notification(outcome); + return; + } + if self.handle_mobile_mouse(mouse, outcome) { + return; + } + if mouse.kind == MouseEventKind::Drag(MouseButton::Left) { + match self.chrome_drag.as_ref() { + Some(ClientChromeDrag::SidebarWidth) => { + self.set_sidebar_width_from_column(mouse.column, outcome); + return; + } + Some(ClientChromeDrag::SidebarSection) => { + self.set_sidebar_section_from_row(mouse.row, outcome); + return; + } + Some(ClientChromeDrag::WorkspaceScrollbar { grab_row_offset }) => { + if let Some(metrics) = self.hits.workspace_scroll_metrics { + let offset = crate::ui::scrollbar_offset_from_drag_row( + metrics, + self.hits.workspace_scrollbar, + mouse.row, + *grab_row_offset, + ); + let next = metrics.max_offset_from_bottom.saturating_sub(offset); + if next != self.workspace_scroll { + self.workspace_scroll = next; + outcome.repaint = true; + } + } + return; + } + Some(ClientChromeDrag::AgentScrollbar { grab_row_offset }) => { + if let Some(metrics) = self.hits.agent_scroll_metrics { + let offset = crate::ui::scrollbar_offset_from_drag_row( + metrics, + self.hits.agent_scrollbar, + mouse.row, + *grab_row_offset, + ); + let next = metrics.max_offset_from_bottom.saturating_sub(offset); + if next != self.agent_scroll { + self.agent_scroll = next; + outcome.repaint = true; + } + } + return; + } + Some(ClientChromeDrag::HelpScrollbar { grab_row_offset }) => { + if let (Some(metrics), Some(ClientShellOverlay::Help(help))) = + (self.hits.help_scroll_metrics, self.overlay.as_mut()) + { + let offset = crate::ui::scrollbar_offset_from_drag_row( + metrics, + self.hits.help_scrollbar, + mouse.row, + *grab_row_offset, + ); + let next = metrics.max_offset_from_bottom.saturating_sub(offset); + if next != help.scroll { + help.scroll = next; + outcome.repaint = true; + } + } + return; + } + Some( + ClientChromeDrag::ProductAnnouncementScrollbar { .. } + | ClientChromeDrag::ReleaseNotesScrollbar { .. }, + ) => { + self.chrome_drag = None; + return; + } + Some(ClientChromeDrag::PaneScrollbar { + hit, + grab_row_offset, + last_sent_offset, + last_sent_at, + }) => { + let current_hit = self + .hits + .panes + .iter() + .find(|current| current.pane_id == hit.pane_id) + .cloned() + .unwrap_or_else(|| hit.clone()); + let Some(offset) = Self::pane_scrollbar_offset( + ¤t_hit, + mouse.row, + Some(*grab_row_offset), + ) else { + self.chrome_drag = None; + return; + }; + let now = std::time::Instant::now(); + let should_send = *last_sent_offset != Some(offset) + && last_sent_at.is_none_or(|last| { + now.duration_since(last) >= std::time::Duration::from_millis(33) + }); + if should_send { + if let Some(ClientChromeDrag::PaneScrollbar { + last_sent_offset, + last_sent_at, + .. + }) = self.chrome_drag.as_mut() + { + *last_sent_offset = Some(offset); + *last_sent_at = Some(now); + } + self.push_pane_scroll_offset(current_hit.pane_id, offset, outcome); + } + return; + } + Some(ClientChromeDrag::PaneSplit { + hit, + tab_id, + grab_offset, + last_sent_at, + .. + }) => { + let hit = hit.clone(); + let tab_id = tab_id.clone(); + let grab_offset = *grab_offset; + match self.pane_split_target_is_current(&hit, &tab_id) { + Some(true) => {} + Some(false) => { + self.chrome_drag = None; + return; + } + None => return, + } + let ratio = Self::pane_split_ratio(&hit, grab_offset, point); + let now = std::time::Instant::now(); + let should_send = last_sent_at.is_none_or(|last| { + now.duration_since(last) >= std::time::Duration::from_millis(33) + }); + if let Some(ClientChromeDrag::PaneSplit { + last_sent_ratio, + last_sent_at, + .. + }) = self.chrome_drag.as_mut() + { + if should_send { + *last_sent_ratio = Some(ratio); + *last_sent_at = Some(now); + } + } + if should_send { + self.push_endpoint_method( + crate::api::schema::Method::LayoutSetSplitRatio( + crate::api::schema::LayoutSetSplitRatioParams { + tab_id: Some(tab_id), + pane_id: None, + path: hit.path, + ratio, + }, + ), + outcome, + ); + } + return; + } + Some(ClientChromeDrag::Tab { .. }) => { + let insert_index = self.tab_drop_index_at(point); + if let Some(ClientChromeDrag::Tab { + insert_index: current, + .. + }) = self.chrome_drag.as_mut() + { + *current = insert_index; + } + outcome.repaint = true; + return; + } + Some(ClientChromeDrag::Workspace { .. }) => { + let target = self.workspace_drop_target_at(point); + if let Some(ClientChromeDrag::Workspace { + target: current, .. + }) = self.chrome_drag.as_mut() + { + *current = target; + } + outcome.repaint = true; + return; + } + None => {} + } + if let Some(press) = self.workspace_press.as_ref() { + let delta = mouse + .column + .abs_diff(press.start_column) + .max(mouse.row.abs_diff(press.start_row)); + if delta >= 1 { + let source_workspace_id = press.workspace_id.clone(); + let draggable = self + .snapshot + .as_deref() + .and_then(|snapshot| { + snapshot + .workspaces + .iter() + .find(|workspace| workspace.workspace_id == source_workspace_id) + }) + .is_some_and(|workspace| { + !workspace + .worktree + .as_ref() + .is_some_and(|worktree| worktree.is_linked_worktree) + }); + if draggable { + if let Some(target) = self.workspace_drop_target_at(point) { + self.chrome_drag = Some(ClientChromeDrag::Workspace { + source_workspace_id, + target: Some(target), + }); + outcome.repaint = true; + } + } + } + return; + } + if let Some(press) = self.tab_press.as_ref() { + let delta = mouse + .column + .abs_diff(press.start_column) + .max(mouse.row.abs_diff(press.start_row)); + if delta >= 1 { + if let Some(insert_index) = self.tab_drop_index_at(point) { + self.chrome_drag = Some(ClientChromeDrag::Tab { + tab_id: press.tab_id.clone(), + workspace_id: press.workspace_id.clone(), + insert_index: Some(insert_index), + }); + outcome.repaint = true; + } + } + return; + } + } + if mouse.kind == MouseEventKind::Up(MouseButton::Left) { + if let Some(drag) = self.chrome_drag.take() { + self.workspace_press = None; + self.tab_press = None; + match drag { + ClientChromeDrag::Tab { + tab_id, + workspace_id, + .. + } => { + let insert_index = self.tab_drop_index_at(point); + let valid_drop = self.snapshot.as_deref().is_some_and(|snapshot| { + snapshot.focused_workspace_id.as_deref() == Some(workspace_id.as_str()) + && snapshot.tabs.iter().any(|tab| { + tab.tab_id == tab_id && tab.workspace_id == workspace_id + }) + && insert_index.is_some_and(|index| { + index + <= snapshot + .tabs + .iter() + .filter(|tab| tab.workspace_id == workspace_id) + .count() + }) + }); + if valid_drop { + self.push_endpoint_method( + crate::api::schema::Method::TabMove( + crate::api::schema::TabMoveParams { + tab_id, + insert_index: insert_index.unwrap_or_default(), + }, + ), + outcome, + ); + } + outcome.repaint = true; + } + ClientChromeDrag::Workspace { + source_workspace_id, + target, + } => { + if let Some((before_workspace_id, _)) = target { + if let Some(method) = self.workspace_move_method( + &source_workspace_id, + before_workspace_id.as_deref(), + ) { + self.push_endpoint_method(method, outcome); + } + } + outcome.repaint = true; + } + ClientChromeDrag::PaneScrollbar { + hit, + grab_row_offset, + last_sent_offset, + .. + } => { + let current_hit = self + .hits + .panes + .iter() + .find(|current| current.pane_id == hit.pane_id) + .cloned() + .unwrap_or(hit); + if let Some(offset) = Self::pane_scrollbar_offset( + ¤t_hit, + mouse.row, + Some(grab_row_offset), + ) { + if last_sent_offset != Some(offset) { + self.push_pane_scroll_offset(current_hit.pane_id, offset, outcome); + } + } + } + ClientChromeDrag::PaneSplit { + hit, + tab_id, + grab_offset, + last_sent_ratio, + .. + } => { + let target_is_current = + self.pane_split_target_is_current(&hit, &tab_id) == Some(true); + let ratio = Self::pane_split_ratio(&hit, grab_offset, point); + if target_is_current + && last_sent_ratio + .is_none_or(|sent| (sent - ratio).abs() > f32::EPSILON) + { + self.push_endpoint_method( + crate::api::schema::Method::LayoutSetSplitRatio( + crate::api::schema::LayoutSetSplitRatioParams { + tab_id: Some(tab_id), + pane_id: None, + path: hit.path, + ratio, + }, + ), + outcome, + ); + } + } + ClientChromeDrag::SidebarWidth | ClientChromeDrag::SidebarSection => { + self.persist_chrome_preferences(outcome); + } + ClientChromeDrag::WorkspaceScrollbar { .. } + | ClientChromeDrag::AgentScrollbar { .. } + | ClientChromeDrag::HelpScrollbar { .. } + | ClientChromeDrag::ProductAnnouncementScrollbar { .. } + | ClientChromeDrag::ReleaseNotesScrollbar { .. } => {} + } + return; + } + if let Some(press) = self.workspace_press.take() { + self.push_endpoint_method( + crate::api::schema::Method::WorkspaceFocus( + crate::api::schema::WorkspaceTarget { + workspace_id: press.workspace_id, + }, + ), + outcome, + ); + return; + } + if let Some(press) = self.tab_press.take() { + self.push_endpoint_method( + crate::api::schema::Method::TabFocus(crate::api::schema::TabTarget { + tab_id: press.tab_id, + }), + outcome, + ); + return; + } + } + if matches!(self.overlay, Some(ClientShellOverlay::GlobalMenu(_))) { + let row_hit = self + .hits + .global_menu_rows + .iter() + .find(|(rect, _)| super::contains(*rect, point)) + .copied(); + match mouse.kind { + MouseEventKind::Moved => { + if let (Some((_, index)), Some(ClientShellOverlay::GlobalMenu(menu))) = + (row_hit, self.overlay.as_mut()) + { + menu.highlighted = index; + outcome.repaint = true; + } + } + MouseEventKind::Down(MouseButton::Left) => { + if super::contains(self.hits.global_launcher, point) { + self.toggle_global_menu(); + outcome.repaint = true; + } else if let Some((_, index)) = row_hit { + self.activate_global_menu_item(index, outcome); + } else { + self.overlay = None; + outcome.repaint = true; + } + } + _ => {} + } + return; + } + if matches!(self.overlay, Some(ClientShellOverlay::ContextMenu(_))) { + let row_hit = self + .hits + .context_menu_rows + .iter() + .find(|(rect, _)| super::contains(*rect, point)) + .copied(); + match mouse.kind { + MouseEventKind::Moved => { + if let (Some((_, index)), Some(ClientShellOverlay::ContextMenu(menu))) = + (row_hit, self.overlay.as_mut()) + { + menu.highlighted = index; + outcome.repaint = true; + } + } + MouseEventKind::Down(MouseButton::Left) => { + if let Some((_, index)) = row_hit { + self.activate_context_menu_item(index, outcome); + } else { + self.overlay = None; + outcome.repaint = true; + } + } + _ => {} + } + return; + } + if matches!( + self.overlay, + Some( + ClientShellOverlay::WorktreeCreate(_) + | ClientShellOverlay::WorktreeOpen(_) + | ClientShellOverlay::WorktreeRemove(_) + ) + ) { + match mouse.kind { + MouseEventKind::ScrollUp + if matches!(self.overlay, Some(ClientShellOverlay::WorktreeOpen(_))) => + { + self.move_worktree_open_selection(-1); + outcome.repaint = true; + } + MouseEventKind::ScrollDown + if matches!(self.overlay, Some(ClientShellOverlay::WorktreeOpen(_))) => + { + self.move_worktree_open_selection(1); + outcome.repaint = true; + } + MouseEventKind::Down(MouseButton::Left) => { + if super::contains(self.hits.overlay_cancel, point) { + let busy = + matches!( + self.overlay, + Some( + ClientShellOverlay::WorktreeCreate( + ClientWorktreeCreateOverlay { creating: true, .. } + ) | ClientShellOverlay::WorktreeOpen( + ClientWorktreeOpenOverlay { opening: true, .. } + ) | ClientShellOverlay::WorktreeRemove( + ClientWorktreeRemoveOverlay { removing: true, .. } + ) + ) + ); + if !busy { + self.overlay = None; + outcome.repaint = true; + } + } else if super::contains(self.hits.worktree_search, point) { + if let Some(ClientShellOverlay::WorktreeOpen(open)) = self.overlay.as_mut() + { + open.search_focused = true; + outcome.repaint = true; + } + } else if let Some((_, index)) = self + .hits + .worktree_rows + .iter() + .find(|(rect, _)| super::contains(*rect, point)) + .copied() + { + if let Some(ClientShellOverlay::WorktreeOpen(open)) = self.overlay.as_mut() + { + open.selected = index; + } + self.submit_worktree_open(outcome); + } else if super::contains(self.hits.overlay_primary, point) { + match self.overlay.as_ref() { + Some(ClientShellOverlay::WorktreeCreate(_)) => { + self.submit_worktree_create(outcome) + } + Some(ClientShellOverlay::WorktreeOpen(_)) => { + self.submit_worktree_open(outcome) + } + Some(ClientShellOverlay::WorktreeRemove(_)) => { + self.submit_worktree_remove(outcome) + } + _ => {} + } + } + } + _ => {} + } + return; + } + if matches!(self.overlay, Some(ClientShellOverlay::Settings(_))) { + if mouse.kind == MouseEventKind::Down(MouseButton::Left) { + if let Some((_, section)) = self + .hits + .settings_tabs + .iter() + .find(|(rect, _)| super::contains(*rect, point)) + .copied() + { + self.select_settings_section(section, outcome); + } else if let Some((_, index)) = self + .hits + .settings_choices + .iter() + .find(|(rect, _)| super::contains(*rect, point)) + .copied() + { + self.select_settings_choice(index); + let immediate = matches!( + self.overlay, + Some(ClientShellOverlay::Settings(ClientSettingsOverlay { + section: ClientSettingsSection::Indicators + | ClientSettingsSection::Sound + | ClientSettingsSection::Toast, + .. + })) + ); + if immediate { + self.apply_settings_choice(outcome); + } + outcome.repaint = true; + } else if super::contains(self.hits.overlay_primary, point) { + self.apply_settings_choice(outcome); + } else if super::contains(self.hits.overlay_cancel, point) + || !super::contains(self.hits.settings_popup, point) + { + let installing = matches!( + self.overlay, + Some(ClientShellOverlay::Settings(ClientSettingsOverlay { + installing_integrations: true, + .. + })) + ); + if !installing { + self.cancel_settings_overlay(); + outcome.repaint = true; + } + } + } + return; + } + if matches!(self.overlay, Some(ClientShellOverlay::Help(_))) { + match mouse.kind { + MouseEventKind::ScrollUp => { + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + let next = help.scroll.saturating_sub(3); + if next != help.scroll { + help.scroll = next; + outcome.repaint = true; + } + } + } + MouseEventKind::ScrollDown => { + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + let next = help.scroll.saturating_add(3).min(self.hits.help_max_scroll); + if next != help.scroll { + help.scroll = next; + outcome.repaint = true; + } + } + } + MouseEventKind::Down(MouseButton::Left) => { + if super::contains(self.hits.help_scrollbar, point) { + if let Some(metrics) = self.hits.help_scroll_metrics { + if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset( + metrics, + self.hits.help_scrollbar, + mouse.row, + ) { + self.chrome_drag = + Some(ClientChromeDrag::HelpScrollbar { grab_row_offset }); + } else { + let offset = crate::ui::scrollbar_offset_from_row( + metrics, + self.hits.help_scrollbar, + mouse.row, + ); + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() + { + help.scroll = + metrics.max_offset_from_bottom.saturating_sub(offset); + outcome.repaint = true; + } + } + } + } else if super::contains(self.hits.overlay_cancel, point) { + let search_focused = matches!( + self.overlay, + Some(ClientShellOverlay::Help(ClientHelpOverlay { + search_focused: true, + .. + })) + ); + if search_focused { + if let Some(ClientShellOverlay::Help(help)) = self.overlay.as_mut() { + help.search_focused = false; + help.query.clear(); + help.scroll = 0; + } + } else { + self.overlay = None; + } + outcome.repaint = true; + } else if !super::contains(self.hits.help_popup, point) { + self.overlay = None; + outcome.repaint = true; + } + } + _ => {} + } + return; + } + if matches!(self.overlay, Some(ClientShellOverlay::Navigator(_))) { + let row_hit = self + .hits + .navigator_rows + .iter() + .find(|(rect, _)| super::contains(*rect, point)) + .copied(); + match mouse.kind { + MouseEventKind::Moved => { + if let Some((_, index)) = row_hit { + if let Some(ClientShellOverlay::Navigator(navigator)) = + self.overlay.as_mut() + { + navigator.selected = index; + } + outcome.repaint = true; + } + } + MouseEventKind::Down(MouseButton::Left) => { + if super::contains(self.hits.navigator_search, point) { + if let Some(ClientShellOverlay::Navigator(navigator)) = + self.overlay.as_mut() + { + navigator.search_focused = true; + navigator.filter = None; + } + outcome.repaint = true; + } else if let Some((rect, index)) = row_hit { + if let Some(ClientShellOverlay::Navigator(navigator)) = + self.overlay.as_mut() + { + navigator.selected = index; + } + let workspace = self + .snapshot + .as_deref() + .zip(self.overlay.as_ref()) + .and_then(|(snapshot, overlay)| match overlay { + ClientShellOverlay::Navigator(navigator) => { + render::client_navigator_rows(snapshot, navigator) + .get(index) + .map(|row| { + matches!( + row.target, + ClientNavigatorTarget::Workspace(_) + ) + }) + } + _ => None, + }) + .unwrap_or(false); + if workspace && mouse.column <= rect.x.saturating_add(3) { + self.toggle_selected_navigator_workspace(); + outcome.repaint = true; + } else { + self.accept_navigator_selection(outcome); + } + } else if !super::contains(self.hits.navigator_popup, point) { + self.overlay = None; + outcome.repaint = true; + } + } + MouseEventKind::ScrollUp => { + self.move_navigator_selection(-3); + outcome.repaint = true; + } + MouseEventKind::ScrollDown => { + self.move_navigator_selection(3); + outcome.repaint = true; + } + _ => {} + } + return; + } + if self.overlay.is_some() { + if mouse.kind != MouseEventKind::Down(MouseButton::Left) { + return; + } + if super::contains(self.hits.overlay_primary, point) { + match self.overlay.as_ref() { + Some(ClientShellOverlay::Rename(_)) => self.save_rename_overlay(outcome), + Some(ClientShellOverlay::ConfirmClose(_)) => { + let Some(ClientShellOverlay::ConfirmClose(confirm)) = self.overlay.take() + else { + return; + }; + self.push_endpoint_method( + crate::api::schema::Method::WorkspaceClose( + crate::api::schema::WorkspaceCloseParams { + workspace_id: confirm.workspace_id, + close_group: true, + }, + ), + outcome, + ); + outcome.repaint = true; + } + _ => {} + } + } else if super::contains(self.hits.overlay_clear, point) { + if let Some(ClientShellOverlay::Rename(rename)) = self.overlay.as_mut() { + rename.input.clear(); + rename.replace_on_type = false; + outcome.repaint = true; + } + } else { + self.overlay = None; + outcome.repaint = true; + } + return; + } + + if mouse.kind == MouseEventKind::Drag(MouseButton::Left) { + let selection_hit = self.selection.as_ref().and_then(|selection| { + self.hits + .panes + .iter() + .find(|hit| hit.pane_id == selection.pane_id) + .cloned() + }); + if let Some(hit) = selection_hit { + self.update_selection_drag(&hit, mouse.column, mouse.row, outcome); + outcome.repaint = true; + return; + } + } + if mouse.kind == MouseEventKind::Up(MouseButton::Left) && self.selection.is_some() { + self.stop_selection_autoscroll(); + let copied = self + .selection + .as_mut() + .is_some_and(crate::selection::Selection::finish); + if copied && self.config.copy_on_select { + self.request_selection_copy(outcome); + self.selection = None; + } else if !copied { + self.selection = None; + } + if copied { + self.last_pane_click = None; + } + outcome.repaint = true; + return; + } + if self.scroll_in_progress_selection(mouse, outcome) { + return; + } + + match mouse.kind { + MouseEventKind::Down(MouseButton::Right) => { + let pane_hit = self + .hits + .panes + .iter() + .find(|hit| super::contains(hit.inner_rect, point)) + .cloned(); + if let Some(hit) = pane_hit { + let pane_owns_right_click = self + .snapshot + .as_deref() + .and_then(|snapshot| { + snapshot + .panes + .iter() + .find(|pane| pane.pane_id == hit.pane_id) + }) + .is_some_and(|pane| pane.right_click_passthrough) + && mouse.modifiers.is_empty(); + let configured_modifiers = self + .config + .right_click_passthrough_modifiers + .filter(|modifiers| *modifiers == mouse.modifiers); + if hit.mouse_reporting + && (pane_owns_right_click || configured_modifiers.is_some()) + { + let stripped_modifiers = + configured_modifiers.unwrap_or(crossterm::event::KeyModifiers::empty()); + self.push_pane_mouse_event( + &hit, + mouse, + mouse.modifiers.difference(stripped_modifiers), + outcome, + ); + self.push_endpoint_method( + crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { + pane_id: hit.pane_id.clone(), + }), + outcome, + ); + self.pane_mouse_gesture = Some(ClientPaneMouseGesture { + last_position: self.pane_mouse_position(&hit, mouse), + hit, + button: MouseButton::Right, + stripped_modifiers, + last_event: mouse, + }); + return; + } + } + if !self.config.mouse_capture { + return; + } + let workspace_id = (!self.sidebar_collapsed) + .then(|| { + self.hits + .workspaces + .iter() + .find(|hit| super::contains(hit.rect, point)) + .map(|hit| hit.workspace_id.clone()) + }) + .flatten(); + if let Some(workspace_id) = workspace_id { + self.open_workspace_context_menu(workspace_id, mouse.column, mouse.row); + outcome.repaint = true; + return; + } + let tab_id = self + .hits + .tabs + .iter() + .find(|(rect, _)| super::contains(*rect, point)) + .map(|(_, tab_id)| tab_id.clone()); + if let Some(tab_id) = tab_id { + self.open_tab_context_menu(tab_id, mouse.column, mouse.row); + outcome.repaint = true; + return; + } + let pane_id = self + .hits + .panes + .iter() + .find(|hit| super::contains(hit.rect, point)) + .map(|hit| hit.pane_id.clone()); + if let Some(pane_id) = pane_id { + self.open_pane_context_menu(pane_id, mouse.column, mouse.row); + outcome.repaint = true; + } + } + MouseEventKind::ScrollUp + if self + .hits + .tabs + .iter() + .any(|(rect, _)| super::contains(*rect, point)) + || super::contains(self.hits.tab_scroll_left, point) + || super::contains(self.hits.tab_scroll_right, point) + || super::contains(self.hits.new_tab, point) => + { + self.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::PreviousTab), + outcome, + ); + } + MouseEventKind::ScrollDown + if self + .hits + .tabs + .iter() + .any(|(rect, _)| super::contains(*rect, point)) + || super::contains(self.hits.tab_scroll_left, point) + || super::contains(self.hits.tab_scroll_right, point) + || super::contains(self.hits.new_tab, point) => + { + self.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::NextTab), + outcome, + ); + } + MouseEventKind::ScrollUp if super::contains(self.hits.agent_body, point) => { + let next = self.agent_scroll.saturating_sub(1); + if next != self.agent_scroll { + self.agent_scroll = next; + outcome.repaint = true; + } + } + MouseEventKind::ScrollDown if super::contains(self.hits.agent_body, point) => { + let next = self + .agent_scroll + .saturating_add(1) + .min(self.hits.agent_max_scroll); + if next != self.agent_scroll { + self.agent_scroll = next; + outcome.repaint = true; + } + } + MouseEventKind::ScrollUp if super::contains(self.hits.workspace_body, point) => { + let next = self.workspace_scroll.saturating_sub(1); + if next != self.workspace_scroll { + self.workspace_scroll = next; + outcome.repaint = true; + } + } + MouseEventKind::ScrollDown if super::contains(self.hits.workspace_body, point) => { + let next = self + .workspace_scroll + .saturating_add(1) + .min(self.hits.workspace_max_scroll); + if next != self.workspace_scroll { + self.workspace_scroll = next; + outcome.repaint = true; + } + } + MouseEventKind::Down(MouseButton::Left) => { + if self.selection.take().is_some() { + outcome.repaint = true; + } + self.stop_selection_autoscroll(); + self.selection_highlight_clear_deadline = None; + self.pending_word_selection = None; + let previous_pane_click = self.last_pane_click.take(); + self.workspace_press = None; + self.tab_press = None; + self.chrome_drag = None; + if super::contains(self.hits.sidebar_divider, point) + && !super::contains(self.hits.sidebar_toggle, point) + { + let now = std::time::Instant::now(); + let double_click = self.last_sidebar_divider_click.is_some_and(|last| { + now.duration_since(last) <= std::time::Duration::from_millis(350) + }); + self.last_sidebar_divider_click = Some(now); + if double_click { + self.sidebar_width = self.config.sidebar_width; + self.sidebar_width_manual = false; + self.invalidate_pane_surface(); + outcome.repaint = true; + outcome.resize = true; + self.persist_chrome_preferences(outcome); + } else { + self.chrome_drag = Some(ClientChromeDrag::SidebarWidth); + self.set_sidebar_width_from_column(mouse.column, outcome); + } + return; + } + if super::contains(self.hits.sidebar_section_divider, point) { + self.chrome_drag = Some(ClientChromeDrag::SidebarSection); + self.set_sidebar_section_from_row(mouse.row, outcome); + return; + } + if super::contains(self.hits.workspace_scrollbar, point) { + if let Some(metrics) = self.hits.workspace_scroll_metrics { + if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset( + metrics, + self.hits.workspace_scrollbar, + mouse.row, + ) { + self.chrome_drag = + Some(ClientChromeDrag::WorkspaceScrollbar { grab_row_offset }); + } else { + let offset = crate::ui::scrollbar_offset_from_row( + metrics, + self.hits.workspace_scrollbar, + mouse.row, + ); + let next = metrics.max_offset_from_bottom.saturating_sub(offset); + if next != self.workspace_scroll { + self.workspace_scroll = next; + outcome.repaint = true; + } + } + } + return; + } + if super::contains(self.hits.agent_scrollbar, point) { + if let Some(metrics) = self.hits.agent_scroll_metrics { + if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset( + metrics, + self.hits.agent_scrollbar, + mouse.row, + ) { + self.chrome_drag = + Some(ClientChromeDrag::AgentScrollbar { grab_row_offset }); + } else { + let offset = crate::ui::scrollbar_offset_from_row( + metrics, + self.hits.agent_scrollbar, + mouse.row, + ); + let next = metrics.max_offset_from_bottom.saturating_sub(offset); + if next != self.agent_scroll { + self.agent_scroll = next; + outcome.repaint = true; + } + } + } + return; + } + if super::contains(self.hits.agent_sort_toggle, point) { + let sort = match self.config.agent_panel_sort { + crate::config::AgentPanelSortConfig::Spaces => { + crate::config::AgentPanelSortConfig::Priority + } + crate::config::AgentPanelSortConfig::Priority => { + crate::config::AgentPanelSortConfig::Spaces + } + }; + self.config.agent_panel_sort = sort; + self.agent_panel_sort_manual = true; + self.agent_scroll = 0; + self.persist_chrome_preferences(outcome); + outcome.repaint = true; + return; + } + if super::contains(self.hits.global_launcher, point) { + self.toggle_global_menu(); + outcome.repaint = true; + return; + } + if super::contains(self.hits.new_workspace, point) { + self.record_binding( + crate::input::KeybindMatch::Action( + crate::input::KeybindAction::NewWorkspace, + ), + outcome, + ); + return; + } + if super::contains(self.hits.new_tab, point) { + self.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::NewTab), + outcome, + ); + return; + } + if super::contains(self.hits.tab_scroll_left, point) { + self.tab_scroll = self.tab_scroll.saturating_sub(1); + outcome.repaint = true; + return; + } + if super::contains(self.hits.tab_scroll_right, point) { + let tab_count = self + .snapshot + .as_deref() + .and_then(|snapshot| { + snapshot.focused_workspace_id.as_deref().map(|id| { + snapshot + .tabs + .iter() + .filter(|tab| tab.workspace_id == id) + .count() + }) + }) + .unwrap_or(0); + self.tab_scroll = self + .tab_scroll + .saturating_add(1) + .min(tab_count.saturating_sub(1)); + outcome.repaint = true; + return; + } + if super::contains(self.hits.sidebar_toggle, point) { + self.sidebar_collapsed = !self.sidebar_collapsed; + self.sidebar_collapsed_manual = true; + self.invalidate_pane_surface(); + outcome.repaint = true; + outcome.resize = true; + self.persist_chrome_preferences(outcome); + return; + } + for hit in &self.hits.workspaces { + if let Some((rect, key)) = &hit.group_toggle { + if super::contains(*rect, point) { + if !self.collapsed_groups.remove(key) { + self.collapsed_groups.insert(key.clone()); + } + outcome.repaint = true; + self.persist_chrome_preferences(outcome); + return; + } + } + } + let workspace_press = self + .hits + .workspaces + .iter() + .find(|hit| super::contains(hit.rect, point)) + .map(|hit| ClientWorkspacePress { + workspace_id: hit.workspace_id.clone(), + start_column: mouse.column, + start_row: mouse.row, + }); + if let Some(workspace_press) = workspace_press { + self.workspace_press = Some(workspace_press); + return; + } + let tab_press = self + .config + .mouse_capture + .then(|| { + self.hits + .tabs + .iter() + .find(|(rect, _)| super::contains(*rect, point)) + .and_then(|(_, tab_id)| { + let tab = self + .snapshot + .as_deref()? + .tabs + .iter() + .find(|tab| tab.tab_id == *tab_id)?; + Some(ClientTabPress { + tab_id: tab.tab_id.clone(), + workspace_id: tab.workspace_id.clone(), + start_column: mouse.column, + start_row: mouse.row, + }) + }) + }) + .flatten(); + if let Some(tab_press) = tab_press { + self.tab_press = Some(tab_press); + return; + } + let agent_pane_id = self + .hits + .agents + .iter() + .find(|(rect, _)| super::contains(*rect, point)) + .map(|(_, pane_id)| pane_id.clone()); + if let Some(pane_id) = agent_pane_id { + self.push_endpoint_method( + crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { + pane_id, + }), + outcome, + ); + return; + } + let scrollbar_hit = self + .hits + .panes + .iter() + .find(|hit| { + hit.scrollbar_rect + .is_some_and(|rect| super::contains(rect, point)) + && hit + .scroll + .is_some_and(|metrics| metrics.max_offset_from_bottom > 0) + }) + .cloned(); + if let Some(hit) = scrollbar_hit { + self.mode = ClientShellMode::Terminal; + self.push_endpoint_method( + crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { + pane_id: hit.pane_id.clone(), + }), + outcome, + ); + let (Some(track), Some(metrics)) = (hit.scrollbar_rect, hit.scroll) else { + return; + }; + if let Some(grab_row_offset) = + crate::ui::scrollbar_thumb_grab_offset(metrics, track, mouse.row) + { + self.chrome_drag = Some(ClientChromeDrag::PaneScrollbar { + hit, + grab_row_offset, + last_sent_offset: None, + last_sent_at: None, + }); + } else if let Some(offset) = Self::pane_scrollbar_offset(&hit, mouse.row, None) + { + self.push_pane_scroll_offset(hit.pane_id, offset, outcome); + } + return; + } + let split_hit = self + .hits + .pane_splits + .iter() + .find(|hit| super::contains(hit.hit_rect, point)) + .cloned(); + if let Some(hit) = split_hit { + let Some(tab_id) = self + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.focused_tab_id.clone()) + else { + return; + }; + let pointer = match hit.direction { + crate::protocol::PaneSurfaceSplitDirection::Horizontal => mouse.column, + crate::protocol::PaneSurfaceSplitDirection::Vertical => mouse.row, + }; + self.chrome_drag = Some(ClientChromeDrag::PaneSplit { + grab_offset: i32::from(hit.pos) - i32::from(pointer), + last_sent_ratio: None, + last_sent_at: None, + hit, + tab_id, + }); + return; + } + let pane_hit = self + .hits + .panes + .iter() + .find(|hit| super::contains(hit.rect, point)) + .cloned(); + if let Some(hit) = pane_hit { + if hit.mouse_reporting && super::contains(hit.inner_rect, point) { + self.push_pane_mouse_event(&hit, mouse, mouse.modifiers, outcome); + self.pane_mouse_gesture = Some(ClientPaneMouseGesture { + last_position: self.pane_mouse_position(&hit, mouse), + hit: hit.clone(), + button: MouseButton::Left, + stripped_modifiers: crossterm::event::KeyModifiers::empty(), + last_event: mouse, + }); + } else if super::contains(hit.inner_rect, point) { + let click = ClientPaneClick { + pane_id: hit.pane_id.clone(), + viewport_row: mouse.row.saturating_sub(hit.inner_rect.y), + col: mouse.column.saturating_sub(hit.inner_rect.x), + at: std::time::Instant::now(), + }; + if mouse.modifiers.is_empty() + && previous_pane_click + .as_ref() + .is_some_and(|previous| previous.is_double_click_for(&click)) + { + self.request_word_selection( + &hit, + click.viewport_row, + click.col, + outcome, + ); + } else { + if mouse.modifiers.is_empty() { + self.last_pane_click = Some(click); + } + self.selection = Some(crate::selection::Selection::anchor( + hit.pane_id.clone(), + mouse.row.saturating_sub(hit.inner_rect.y), + mouse.column.saturating_sub(hit.inner_rect.x), + hit.scroll, + )); + } + } + self.push_endpoint_method( + crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { + pane_id: hit.pane_id, + }), + outcome, + ); + } + } + MouseEventKind::Down(MouseButton::Middle) => { + if let Some(hit) = self + .hits + .panes + .iter() + .find(|hit| super::contains(hit.inner_rect, point) && hit.mouse_reporting) + .cloned() + { + self.push_pane_mouse_event(&hit, mouse, mouse.modifiers, outcome); + self.pane_mouse_gesture = Some(ClientPaneMouseGesture { + last_position: self.pane_mouse_position(&hit, mouse), + hit, + button: MouseButton::Middle, + stripped_modifiers: crossterm::event::KeyModifiers::empty(), + last_event: mouse, + }); + } + } + MouseEventKind::Up(MouseButton::Left | MouseButton::Middle) + | MouseEventKind::Drag(MouseButton::Left | MouseButton::Middle) => {} + MouseEventKind::Moved => { + if let Some(hit) = self + .hits + .panes + .iter() + .find(|hit| super::contains(hit.inner_rect, point) && hit.mouse_reporting) + .cloned() + { + self.push_pane_mouse_event(&hit, mouse, mouse.modifiers, outcome); + } + } + MouseEventKind::ScrollUp + | MouseEventKind::ScrollDown + | MouseEventKind::ScrollLeft + | MouseEventKind::ScrollRight => { + if let Some(hit) = self + .hits + .panes + .iter() + .find(|hit| super::contains(hit.inner_rect, point)) + .cloned() + { + if self.focused_pane_id().as_deref() != Some(hit.pane_id.as_str()) { + self.push_endpoint_method( + crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { + pane_id: hit.pane_id.clone(), + }), + outcome, + ); + } + self.push_pane_mouse_event(&hit, mouse, mouse.modifiers, outcome); + } + } + _ => {} + } + } + + fn pane_mouse_position(&self, hit: &PaneHit, mouse: MouseEvent) -> ClientMousePosition { + let cell = ClientMousePosition::Cell { + column: mouse.column.saturating_sub(hit.inner_rect.x), + row: mouse.row.saturating_sub(hit.inner_rect.y), + }; + if hit.sgr_pixel_mouse && hit.pixel_width > 0 && hit.pixel_height > 0 { + self.host_mouse_pixels + .and_then(|pixels| { + pixels + .pane_position(hit.inner_rect, hit.pixel_width, hit.pixel_height) + .and_then(|position| match position { + crate::input::mouse::Position::Pixels { x, y } => { + Some(ClientMousePosition::Pixels { + x, + y, + column: mouse.column.saturating_sub(hit.inner_rect.x), + row: mouse.row.saturating_sub(hit.inner_rect.y), + }) + } + crate::input::mouse::Position::Cell { .. } => None, + }) + }) + .unwrap_or(cell) + } else { + cell + } + } + + pub(super) fn push_pane_mouse_event( + &self, + hit: &PaneHit, + mouse: MouseEvent, + modifiers: crossterm::event::KeyModifiers, + outcome: &mut ClientShellInput, + ) { + let Some(kind) = crate::protocol::ClientMouseKind::from_crossterm(mouse.kind) else { + return; + }; + let position = self.pane_mouse_position(hit, mouse); + let geometry = matches!(position, ClientMousePosition::Pixels { .. }).then_some( + crate::protocol::ClientMouseGeometry { + cols: hit.inner_rect.width, + rows: hit.inner_rect.height, + width_px: hit.pixel_width, + height_px: hit.pixel_height, + }, + ); + let target = if hit.popup { + ClientInputTarget::Popup(hit.pane_id.clone()) + } else { + ClientInputTarget::Pane(hit.pane_id.clone()) + }; + push_target_event( + target, + ClientPaneInputEvent::Mouse { + kind, + position, + geometry, + modifiers: modifiers.bits(), + lines: self.config.mouse_scroll_lines.min(u16::MAX as usize) as u16, + }, + outcome, + ); + } +} |
