mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-22 00:01:06 +00:00
* fix: auto-expire worktree action error banners refs #1827 * fix: restart endpoint error timeout on repeated errors Route every transient client-side action error through a setter that installs a fresh deadline, so an identical repeated message cannot inherit an older, nearly-expired deadline. refs #1827 * test: derive endpoint error expiry assertions from stored deadline refs #1827
2354 lines
98 KiB
Rust
2354 lines
98 KiB
Rust
use super::*;
|
|
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
|
|
|
|
const SELECTION_AUTOSCROLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(30);
|
|
const SELECTION_REPAINT_INTERVAL: std::time::Duration = std::time::Duration::from_millis(16);
|
|
|
|
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);
|
|
if !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: pane_id.clone(),
|
|
serial,
|
|
},
|
|
outcome,
|
|
) {
|
|
self.pane_scroll_targets.remove(&pane_id);
|
|
self.pane_scroll_in_flight.remove(&pane_id);
|
|
}
|
|
}
|
|
|
|
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.set_endpoint_error("endpoint returned an unexpected pane-scroll result");
|
|
true
|
|
}
|
|
Err(_) => {
|
|
self.pane_scroll_queued.remove(&pane_id);
|
|
self.pane_scroll_targets.remove(&pane_id);
|
|
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 active_selection_pane(&self) -> Option<PaneHit> {
|
|
let pane_id = if let Some(gesture) = self.word_selection_gesture.as_ref() {
|
|
if gesture.released {
|
|
return None;
|
|
}
|
|
&gesture.pane_id
|
|
} else {
|
|
&self
|
|
.selection
|
|
.as_ref()
|
|
.filter(|selection| selection.is_in_progress())?
|
|
.pane_id
|
|
};
|
|
self.hits
|
|
.panes
|
|
.iter()
|
|
.find(|hit| &hit.pane_id == pane_id)
|
|
.cloned()
|
|
}
|
|
|
|
fn update_selection_cursor_with_metrics(
|
|
&mut self,
|
|
hit: &PaneHit,
|
|
column: u16,
|
|
row: u16,
|
|
metrics: Option<crate::pane::ScrollMetrics>,
|
|
outcome: &mut ClientShellInput,
|
|
) {
|
|
if self.word_selection_gesture.is_some() {
|
|
let viewport_row = row
|
|
.saturating_sub(hit.inner_rect.y)
|
|
.min(hit.inner_rect.height.saturating_sub(1));
|
|
let col = column
|
|
.saturating_sub(hit.inner_rect.x)
|
|
.min(hit.inner_rect.width.saturating_sub(1));
|
|
let absolute_row = crate::selection::absolute_row_for_viewport(viewport_row, metrics);
|
|
self.drag_word_selection((absolute_row, col), outcome);
|
|
} else 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
|
|
});
|
|
self.update_selection_cursor_with_metrics(hit, column, row, metrics, outcome);
|
|
let is_dragging = self
|
|
.word_selection_gesture
|
|
.as_ref()
|
|
.map_or(was_dragging || moved_from_anchor, |gesture| gesture.dragged);
|
|
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), outcome);
|
|
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
|
|
) {
|
|
return false;
|
|
}
|
|
let Some(hit) = self.active_selection_pane() 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),
|
|
outcome,
|
|
);
|
|
self.push_pane_scroll_offset(hit.pane_id, offset_from_bottom, outcome);
|
|
outcome.repaint = true;
|
|
}
|
|
true
|
|
}
|
|
|
|
pub(super) fn request_selection_drag_repaint(&mut self, now: std::time::Instant) -> bool {
|
|
let deadline = self
|
|
.last_composed_at
|
|
.map(|last| last + SELECTION_REPAINT_INTERVAL);
|
|
self.selection_repaint_deadline = deadline.filter(|deadline| now < *deadline);
|
|
self.selection_repaint_deadline.is_none()
|
|
}
|
|
|
|
pub(crate) fn tick_selection_autoscroll(
|
|
&mut self,
|
|
now: std::time::Instant,
|
|
) -> ClientShellInput {
|
|
let mut outcome = ClientShellInput::default();
|
|
if self
|
|
.selection_repaint_deadline
|
|
.is_some_and(|deadline| now >= deadline)
|
|
{
|
|
self.selection_repaint_deadline = None;
|
|
outcome.repaint = true;
|
|
}
|
|
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;
|
|
};
|
|
let dragging = self.word_selection_gesture.as_ref().map_or_else(
|
|
|| {
|
|
self.selection.as_ref().is_some_and(|selection| {
|
|
selection.pane_id == autoscroll.pane_id && selection.is_dragging()
|
|
})
|
|
},
|
|
|gesture| gesture.pane_id == autoscroll.pane_id && gesture.dragged && !gesture.released,
|
|
);
|
|
if !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),
|
|
&mut outcome,
|
|
);
|
|
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
|
|
|| self.hits.workspaces.iter().any(|hit| {
|
|
hit.endpoint_id != self.active_endpoint_id && super::contains(hit.rect, point)
|
|
})
|
|
{
|
|
return None;
|
|
}
|
|
let mut slots = self
|
|
.hits
|
|
.workspaces
|
|
.iter()
|
|
.filter(|hit| hit.endpoint_id == self.active_endpoint_id && !hit.indented)
|
|
.map(|hit| (Some(hit.workspace_id.clone()), hit.rect.y.saturating_sub(1)))
|
|
.collect::<Vec<_>>();
|
|
let snapshot = self.snapshot.as_deref()?;
|
|
let empty_collapsed_groups = HashSet::new();
|
|
let collapsed_groups = self
|
|
.collapsed_groups_for_endpoint(&self.active_endpoint_id)
|
|
.unwrap_or(&empty_collapsed_groups);
|
|
let entries = render::workspace_entries(snapshot, collapsed_groups);
|
|
let last_hit = self
|
|
.hits
|
|
.workspaces
|
|
.iter()
|
|
.rev()
|
|
.find(|hit| hit.endpoint_id == self.active_endpoint_id)?;
|
|
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 self.mode == ClientShellMode::Navigate
|
|
&& self.workspace_preview_action_blocked()
|
|
&& self.overlay.is_none()
|
|
&& !self.mobile_layout_active()
|
|
&& mouse.kind == MouseEventKind::Down(MouseButton::Left)
|
|
{
|
|
self.mode = self.copy_or_terminal_mode();
|
|
self.navigate_workspace_id = None;
|
|
outcome.repaint = true;
|
|
}
|
|
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.visible_endpoint_notice.is_some()
|
|
&& mouse.kind == MouseEventKind::Down(MouseButton::Left)
|
|
&& super::contains(self.hits.notification_toast, point)
|
|
{
|
|
self.visible_endpoint_notice = None;
|
|
outcome.repaint = true;
|
|
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.endpoint_workspace_is_draggable(press);
|
|
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.finish_endpoint_workspace_press(press, 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))
|
|
.cloned();
|
|
match mouse.kind {
|
|
MouseEventKind::Moved => {
|
|
if let Some((_, target)) = row_hit {
|
|
if let Some(ClientShellOverlay::Navigator(navigator)) =
|
|
self.overlay.as_mut()
|
|
{
|
|
navigator.selected = Some(target);
|
|
}
|
|
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, target)) = row_hit {
|
|
if let Some(ClientShellOverlay::Navigator(navigator)) =
|
|
self.overlay.as_mut()
|
|
{
|
|
navigator.selected = Some(target.clone());
|
|
}
|
|
let workspace = matches!(target, ClientNavigatorTarget::Workspace { .. });
|
|
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.active_selection_pane();
|
|
if let Some(hit) = selection_hit {
|
|
self.update_selection_drag(&hit, mouse.column, mouse.row, outcome);
|
|
// Consume every motion, but do not rebuild a frame for every intermediate position.
|
|
outcome.repaint |= !outcome.actions.is_empty()
|
|
|| self.request_selection_drag_repaint(std::time::Instant::now());
|
|
return;
|
|
}
|
|
}
|
|
if mouse.kind == MouseEventKind::Up(MouseButton::Left)
|
|
&& self.word_selection_gesture.is_some()
|
|
{
|
|
self.finish_word_selection(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, false);
|
|
self.selection = None;
|
|
} else if self
|
|
.selection
|
|
.as_ref()
|
|
.is_some_and(crate::selection::Selection::is_just_click)
|
|
{
|
|
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.active_endpoint_workspace_at(point))
|
|
.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.word_selection_gesture = 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 self.handle_endpoint_machine_click(point, outcome) {
|
|
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;
|
|
}
|
|
let group_toggle = self.hits.workspaces.iter().find_map(|hit| {
|
|
let (rect, key) = hit.group_toggle.as_ref()?;
|
|
super::contains(*rect, point).then(|| (hit.endpoint_id.clone(), key.clone()))
|
|
});
|
|
if let Some((endpoint_id, key)) = group_toggle {
|
|
self.toggle_collapsed_group(&endpoint_id, key);
|
|
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 {
|
|
endpoint_id: hit.endpoint_id.clone(),
|
|
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;
|
|
}
|
|
if self.handle_endpoint_agent_click(point, outcome) {
|
|
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,
|
|
);
|
|
}
|
|
}
|