aboutsummaryrefslogtreecommitdiff
path: root/src/app/input/mouse.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/app/input/mouse.rs')
-rw-r--r--src/app/input/mouse.rs1258
1 files changed, 1258 insertions, 0 deletions
diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs
new file mode 100644
index 00000000..0349d236
--- /dev/null
+++ b/src/app/input/mouse.rs
@@ -0,0 +1,1258 @@
+use bytes::Bytes;
+use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
+use ratatui::layout::{Direction, Rect};
+use tracing::warn;
+
+use crate::{
+ app::state::{
+ AgentPanelScope, AppState, ContextMenuKind, ContextMenuState, DragState, DragTarget,
+ MenuListState, Mode, TabPressState, WorkspacePressState,
+ },
+ layout::{PaneInfo, SplitBorder},
+ selection::Selection,
+};
+
+#[cfg(test)]
+use super::WheelRouting;
+use super::{
+ modal::{
+ apply_context_menu_action, apply_global_menu_action, apply_rename_action,
+ confirm_close_accept, confirm_close_cancel, global_menu_actions, leave_modal,
+ modal_action_from_buttons, open_global_menu, open_new_tab_dialog, ModalAction,
+ },
+ settings::SettingsAction,
+ ScrollbarClickTarget, TAB_DRAG_THRESHOLD, WORKSPACE_DRAG_THRESHOLD,
+};
+
+impl AppState {
+ pub(super) fn handle_mouse(&mut self, mouse: MouseEvent) -> Option<SettingsAction> {
+ if self.mode == Mode::Onboarding {
+ self.handle_onboarding_mouse(mouse);
+ return None;
+ }
+
+ if self.mode == Mode::Settings {
+ return self.handle_settings_mouse(mouse);
+ }
+
+ let launcher_enabled = !self.sidebar_collapsed
+ && matches!(
+ self.mode,
+ Mode::Terminal
+ | Mode::Navigate
+ | Mode::Resize
+ | Mode::GlobalMenu
+ | Mode::KeybindHelp
+ );
+ let launcher = self.global_launcher_rect();
+ let launcher_hit = launcher_enabled
+ && mouse.column >= launcher.x
+ && mouse.column < launcher.x + launcher.width
+ && mouse.row >= launcher.y
+ && mouse.row < launcher.y + launcher.height;
+
+ if matches!(mouse.kind, MouseEventKind::Moved) && self.mode == Mode::GlobalMenu {
+ let actions = global_menu_actions(self);
+ let hovered = self
+ .global_menu_item_at(mouse.column, mouse.row)
+ .and_then(|action| actions.iter().position(|item| *item == action));
+ self.global_menu.hover(hovered);
+ return None;
+ }
+
+ if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) && launcher_hit {
+ if self.mode == Mode::GlobalMenu {
+ leave_modal(self);
+ } else {
+ open_global_menu(self);
+ }
+ return None;
+ }
+
+ if self.mode == Mode::GlobalMenu {
+ if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
+ if let Some(action) = self.global_menu_item_at(mouse.column, mouse.row) {
+ apply_global_menu_action(self, action);
+ } else {
+ leave_modal(self);
+ }
+ }
+ return None;
+ }
+
+ if self.mode == Mode::KeybindHelp {
+ return None;
+ }
+
+ let sidebar = self.view.sidebar_rect;
+ let in_sidebar = mouse.column >= sidebar.x
+ && mouse.column < sidebar.x + sidebar.width
+ && mouse.row >= sidebar.y
+ && mouse.row < sidebar.y + sidebar.height;
+
+ match mouse.kind {
+ MouseEventKind::Down(MouseButton::Left) => {
+ self.selection = None;
+ self.workspace_press = None;
+
+ if self.mode == Mode::ConfirmClose {
+ let popup = self.confirm_close_rect();
+ let inner = Rect::new(
+ popup.x + 1,
+ popup.y + 1,
+ popup.width.saturating_sub(2),
+ popup.height.saturating_sub(2),
+ );
+ let (confirm, cancel) = crate::ui::confirm_close_button_rects(inner);
+ match modal_action_from_buttons(
+ mouse.column,
+ mouse.row,
+ &[
+ (confirm, ModalAction::Confirm),
+ (cancel, ModalAction::Cancel),
+ ],
+ ) {
+ Some(ModalAction::Confirm) => confirm_close_accept(self),
+ Some(ModalAction::Cancel) | None => confirm_close_cancel(self),
+ _ => {}
+ }
+ return None;
+ }
+
+ if matches!(self.mode, Mode::RenameWorkspace | Mode::RenameTab) {
+ let action = self
+ .rename_modal_inner()
+ .map(crate::ui::rename_button_rects)
+ .and_then(|(save, clear, cancel)| {
+ modal_action_from_buttons(
+ mouse.column,
+ mouse.row,
+ &[
+ (save, ModalAction::Save),
+ (clear, ModalAction::Clear),
+ (cancel, ModalAction::Cancel),
+ ],
+ )
+ })
+ .unwrap_or(ModalAction::Cancel);
+ apply_rename_action(self, action);
+ return None;
+ }
+
+ if self.mode == Mode::ContextMenu {
+ let item_idx = self.context_menu_item_at(mouse.column, mouse.row);
+ if let Some(menu) = self.context_menu.take() {
+ if let Some(idx) = item_idx {
+ apply_context_menu_action(self, menu, idx);
+ } else {
+ leave_modal(self);
+ }
+ }
+ return None;
+ }
+
+ if self.on_sidebar_divider(mouse.column, mouse.row) {
+ self.drag = Some(DragState {
+ target: DragTarget::SidebarDivider,
+ });
+ self.set_manual_sidebar_width(mouse.column);
+ return None;
+ }
+
+ if self.on_sidebar_section_divider(mouse.column, mouse.row) {
+ self.drag = Some(DragState {
+ target: DragTarget::SidebarSectionDivider,
+ });
+ self.set_sidebar_section_split(mouse.row);
+ return None;
+ }
+
+ if !in_sidebar {
+ if let Some(border) = self.find_border_at(mouse.column, mouse.row) {
+ self.drag = Some(DragState {
+ target: DragTarget::PaneSplit {
+ path: border.path.clone(),
+ direction: border.direction,
+ area: border.area,
+ },
+ });
+ return None;
+ }
+
+ if let Some((pane_id, target)) =
+ self.scrollbar_target_at(mouse.column, mouse.row)
+ {
+ self.focus_pane(pane_id);
+ match target {
+ ScrollbarClickTarget::Thumb { grab_row_offset } => {
+ self.drag = Some(DragState {
+ target: DragTarget::PaneScrollbar {
+ pane_id,
+ grab_row_offset,
+ },
+ });
+ }
+ ScrollbarClickTarget::Track { offset_from_bottom } => {
+ self.set_pane_scroll_offset(pane_id, offset_from_bottom);
+ }
+ }
+ if self.mode != Mode::Terminal {
+ self.mode = Mode::Terminal;
+ }
+ return None;
+ }
+ }
+
+ if self.on_tab_scroll_left_button(mouse.column, mouse.row) {
+ self.scroll_tabs_left();
+ return None;
+ }
+ if self.on_tab_scroll_right_button(mouse.column, mouse.row) {
+ self.scroll_tabs_right();
+ return None;
+ }
+ if let (Some(ws_idx), Some(tab_idx)) =
+ (self.active, self.tab_at(mouse.column, mouse.row))
+ {
+ self.tab_press = Some(TabPressState {
+ ws_idx,
+ tab_idx,
+ start_col: mouse.column,
+ start_row: mouse.row,
+ });
+ return None;
+ }
+ if self.on_new_tab_button(mouse.column, mouse.row) {
+ open_new_tab_dialog(self);
+ return None;
+ }
+
+ if in_sidebar {
+ if self.sidebar_collapsed {
+ if self.on_collapsed_sidebar_toggle(mouse.column, mouse.row) {
+ self.sidebar_collapsed = false;
+ return None;
+ }
+
+ if let Some(idx) = self.collapsed_workspace_at_row(mouse.row) {
+ self.switch_workspace(idx);
+ self.mode = Mode::Terminal;
+ return None;
+ }
+
+ if let Some((ws_idx, tab_idx, pane_id)) =
+ self.collapsed_agent_detail_target_at(mouse.row)
+ {
+ self.switch_workspace(ws_idx);
+ self.switch_tab(tab_idx);
+ self.focus_pane(pane_id);
+ self.mode = Mode::Terminal;
+ }
+ return None;
+ }
+
+ let new_button = self.sidebar_new_button_rect();
+ let on_new_button = mouse.row >= new_button.y
+ && mouse.row < new_button.y + new_button.height
+ && mouse.column >= new_button.x
+ && mouse.column < new_button.x + new_button.width;
+ if on_new_button {
+ self.request_new_workspace = true;
+ return None;
+ }
+
+ if let Some(target) =
+ self.workspace_list_scrollbar_target_at(mouse.column, mouse.row)
+ {
+ match target {
+ ScrollbarClickTarget::Thumb { grab_row_offset } => {
+ self.drag = Some(DragState {
+ target: DragTarget::WorkspaceListScrollbar { grab_row_offset },
+ });
+ }
+ ScrollbarClickTarget::Track { offset_from_bottom } => {
+ self.set_workspace_list_offset_from_bottom(offset_from_bottom);
+ }
+ }
+ return None;
+ }
+
+ if let Some(idx) = self.workspace_at_row(mouse.row) {
+ self.workspace_press = Some(WorkspacePressState {
+ ws_idx: idx,
+ start_col: mouse.column,
+ start_row: mouse.row,
+ });
+ return None;
+ }
+
+ if self.on_agent_panel_scope_toggle(mouse.column, mouse.row) {
+ self.agent_panel_scope = match self.agent_panel_scope {
+ AgentPanelScope::CurrentWorkspace => AgentPanelScope::AllWorkspaces,
+ AgentPanelScope::AllWorkspaces => AgentPanelScope::CurrentWorkspace,
+ };
+ self.agent_panel_scroll = 0;
+ self.mark_session_dirty();
+ return None;
+ }
+
+ if let Some(target) =
+ self.agent_panel_scrollbar_target_at(mouse.column, mouse.row)
+ {
+ match target {
+ ScrollbarClickTarget::Thumb { grab_row_offset } => {
+ self.drag = Some(DragState {
+ target: DragTarget::AgentPanelScrollbar { grab_row_offset },
+ });
+ }
+ ScrollbarClickTarget::Track { offset_from_bottom } => {
+ self.set_agent_panel_offset_from_bottom(offset_from_bottom);
+ }
+ }
+ return None;
+ }
+
+ if let Some((ws_idx, tab_idx, pane_id)) = self.agent_detail_target_at(mouse.row)
+ {
+ self.switch_workspace(ws_idx);
+ self.switch_tab(tab_idx);
+ self.focus_pane(pane_id);
+ self.mode = Mode::Terminal;
+ return None;
+ }
+ } else if let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() {
+ self.focus_pane(info.id);
+ if self.mode != Mode::Terminal {
+ self.mode = Mode::Terminal;
+ }
+
+ if self.forward_pane_mouse_button(&info, mouse) {
+ self.selection = None;
+ return None;
+ }
+
+ let (row, col) = (
+ mouse.row - info.inner_rect.y,
+ mouse.column - info.inner_rect.x,
+ );
+ self.selection = Some(Selection::anchor(
+ info.id,
+ row,
+ col,
+ self.pane_scroll_metrics(info.id),
+ ));
+ } else if let Some(info) = self.view.pane_infos.iter().find(|p| {
+ mouse.column >= p.rect.x
+ && mouse.column < p.rect.x + p.rect.width
+ && mouse.row >= p.rect.y
+ && mouse.row < p.rect.y + p.rect.height
+ }) {
+ let id = info.id;
+ self.focus_pane(id);
+ if self.mode != Mode::Terminal {
+ self.mode = Mode::Terminal;
+ }
+ }
+ }
+
+ MouseEventKind::Drag(MouseButton::Left) => {
+ if self.selection.is_some() {
+ self.update_selection_drag(mouse.column, mouse.row);
+ return None;
+ }
+
+ if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() {
+ if self.forward_pane_mouse_button(&info, mouse) {
+ self.selection = None;
+ return None;
+ }
+ }
+
+ let workspace_drop_index = self.workspace_drop_index_at_row(mouse.row);
+ let tab_drop_index = self.tab_drop_index_at(mouse.column, mouse.row);
+ if self.drag.is_none() {
+ if let Some(press) = &self.workspace_press {
+ let delta_col = mouse.column.abs_diff(press.start_col);
+ let delta_row = mouse.row.abs_diff(press.start_row);
+ if delta_col.max(delta_row) >= WORKSPACE_DRAG_THRESHOLD {
+ self.drag = Some(DragState {
+ target: DragTarget::WorkspaceReorder {
+ source_ws_idx: press.ws_idx,
+ insert_idx: workspace_drop_index,
+ },
+ });
+ }
+ } else if let Some(press) = &self.tab_press {
+ let delta_col = mouse.column.abs_diff(press.start_col);
+ let delta_row = mouse.row.abs_diff(press.start_row);
+ if delta_col.max(delta_row) >= TAB_DRAG_THRESHOLD {
+ self.drag = Some(DragState {
+ target: DragTarget::TabReorder {
+ ws_idx: press.ws_idx,
+ source_tab_idx: press.tab_idx,
+ insert_idx: tab_drop_index,
+ },
+ });
+ }
+ }
+ }
+
+ if let Some(DragState {
+ target: DragTarget::WorkspaceReorder { insert_idx, .. },
+ }) = &mut self.drag
+ {
+ *insert_idx = workspace_drop_index;
+ } else if let Some(DragState {
+ target:
+ DragTarget::TabReorder {
+ ws_idx, insert_idx, ..
+ },
+ }) = &mut self.drag
+ {
+ if self.active == Some(*ws_idx) {
+ *insert_idx = tab_drop_index;
+ }
+ } else if let Some(drag) = &self.drag {
+ match &drag.target {
+ DragTarget::WorkspaceReorder { .. } | DragTarget::TabReorder { .. } => {}
+ DragTarget::WorkspaceListScrollbar { grab_row_offset } => {
+ if let Some(offset_from_bottom) =
+ self.workspace_list_offset_for_drag_row(mouse.row, *grab_row_offset)
+ {
+ self.set_workspace_list_offset_from_bottom(offset_from_bottom);
+ }
+ }
+ DragTarget::AgentPanelScrollbar { grab_row_offset } => {
+ if let Some(offset_from_bottom) =
+ self.agent_panel_offset_for_drag_row(mouse.row, *grab_row_offset)
+ {
+ self.set_agent_panel_offset_from_bottom(offset_from_bottom);
+ }
+ }
+ DragTarget::PaneSplit {
+ path,
+ direction,
+ area,
+ } => {
+ let ratio = match direction {
+ Direction::Horizontal => {
+ (mouse.column.saturating_sub(area.x)) as f32
+ / area.width.max(1) as f32
+ }
+ Direction::Vertical => {
+ (mouse.row.saturating_sub(area.y)) as f32
+ / area.height.max(1) as f32
+ }
+ };
+ let ratio = ratio.clamp(0.1, 0.9);
+ let path = path.clone();
+ if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) {
+ ws.layout.set_ratio_at(&path, ratio);
+ self.mark_session_dirty();
+ }
+ }
+ DragTarget::PaneScrollbar {
+ pane_id,
+ grab_row_offset,
+ } => {
+ if let Some(offset_from_bottom) = self.scrollbar_offset_for_pane_row(
+ *pane_id,
+ mouse.row,
+ *grab_row_offset,
+ ) {
+ self.set_pane_scroll_offset(*pane_id, offset_from_bottom);
+ }
+ }
+ DragTarget::SidebarDivider => {
+ self.set_manual_sidebar_width(mouse.column);
+ }
+ DragTarget::SidebarSectionDivider => {
+ self.set_sidebar_section_split(mouse.row);
+ }
+ DragTarget::ReleaseNotesScrollbar { .. }
+ | DragTarget::KeybindHelpScrollbar { .. } => {}
+ }
+ }
+ }
+
+ MouseEventKind::Up(MouseButton::Left) => {
+ if self.selection.is_some() {
+ self.workspace_press = None;
+ self.tab_press = None;
+ self.drag = None;
+ let was_click = self.selection.as_ref().is_some_and(|s| s.was_just_click());
+ if was_click {
+ self.selection = None;
+ } else {
+ self.copy_selection();
+ }
+ return None;
+ }
+
+ if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() {
+ if self.forward_pane_mouse_button(&info, mouse) {
+ self.selection = None;
+ self.workspace_press = None;
+ self.tab_press = None;
+ self.drag = None;
+ return None;
+ }
+ }
+
+ let workspace_press = self.workspace_press.take();
+ let tab_press = self.tab_press.take();
+ match self.drag.take() {
+ Some(DragState {
+ target:
+ DragTarget::WorkspaceReorder {
+ source_ws_idx,
+ insert_idx: Some(insert_idx),
+ },
+ }) => {
+ self.move_workspace(source_ws_idx, insert_idx);
+ }
+ Some(DragState {
+ target:
+ DragTarget::TabReorder {
+ ws_idx,
+ source_tab_idx,
+ insert_idx: Some(insert_idx),
+ },
+ }) => {
+ if self.active == Some(ws_idx) {
+ self.move_tab(source_tab_idx, insert_idx);
+ self.mode = Mode::Terminal;
+ }
+ }
+ Some(_) => {}
+ None => {
+ if let Some(press) = workspace_press {
+ self.switch_workspace(press.ws_idx);
+ self.mode = Mode::Terminal;
+ return None;
+ }
+ if let Some(press) = tab_press {
+ if self.active == Some(press.ws_idx) {
+ self.switch_tab(press.tab_idx);
+ self.mode = Mode::Terminal;
+ return None;
+ }
+ }
+ let was_click = self.selection.as_ref().is_some_and(|s| s.was_just_click());
+ if was_click {
+ self.selection = None;
+ } else {
+ self.copy_selection();
+ }
+ }
+ }
+ }
+
+ MouseEventKind::Up(MouseButton::Middle) | MouseEventKind::Drag(MouseButton::Middle)
+ if !in_sidebar =>
+ {
+ if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() {
+ let _ = self.forward_pane_mouse_button(&info, mouse);
+ }
+ }
+
+ MouseEventKind::ScrollUp | MouseEventKind::ScrollDown if !in_sidebar => {
+ if self.on_tab_bar(mouse.column, mouse.row) {
+ match mouse.kind {
+ MouseEventKind::ScrollUp => self.scroll_tabs_left(),
+ MouseEventKind::ScrollDown => self.scroll_tabs_right(),
+ _ => {}
+ }
+ } else if !self.scroll_selection_with_wheel(mouse) {
+ self.selection = None;
+ self.handle_terminal_wheel(mouse);
+ }
+ }
+
+ MouseEventKind::ScrollUp if in_sidebar => {
+ let agent_area = self.agent_panel_rect();
+ let over_agent_panel = agent_area != Rect::default()
+ && mouse.row >= agent_area.y
+ && mouse.row < agent_area.y + agent_area.height;
+ if over_agent_panel {
+ if crate::ui::should_show_scrollbar(crate::ui::agent_panel_scroll_metrics(
+ self, agent_area,
+ )) {
+ self.scroll_agent_panel(-1);
+ }
+ } else if crate::ui::should_show_scrollbar(
+ crate::ui::workspace_list_scroll_metrics(self, self.workspace_list_rect()),
+ ) {
+ self.scroll_workspace_list(-1);
+ } else if self.selected > 0 {
+ self.selected -= 1;
+ self.ensure_workspace_visible(self.selected);
+ }
+ }
+ MouseEventKind::ScrollDown if in_sidebar => {
+ let agent_area = self.agent_panel_rect();
+ let over_agent_panel = agent_area != Rect::default()
+ && mouse.row >= agent_area.y
+ && mouse.row < agent_area.y + agent_area.height;
+ if over_agent_panel {
+ if crate::ui::should_show_scrollbar(crate::ui::agent_panel_scroll_metrics(
+ self, agent_area,
+ )) {
+ self.scroll_agent_panel(1);
+ }
+ } else if crate::ui::should_show_scrollbar(
+ crate::ui::workspace_list_scroll_metrics(self, self.workspace_list_rect()),
+ ) {
+ self.scroll_workspace_list(1);
+ } else if !self.workspaces.is_empty() && self.selected < self.workspaces.len() - 1 {
+ self.selected += 1;
+ self.ensure_workspace_visible(self.selected);
+ }
+ }
+
+ MouseEventKind::Moved if self.mode == Mode::ContextMenu => {
+ let hovered = self.context_menu_item_at(mouse.column, mouse.row);
+ if let Some(menu) = &mut self.context_menu {
+ menu.list.hover(hovered);
+ }
+ }
+
+ MouseEventKind::Down(MouseButton::Right) if in_sidebar && !self.sidebar_collapsed => {
+ if self
+ .workspace_list_scrollbar_target_at(mouse.column, mouse.row)
+ .is_some()
+ {
+ return None;
+ }
+ if let Some(idx) = self.workspace_at_row(mouse.row) {
+ self.selected = idx;
+ self.context_menu = Some(ContextMenuState {
+ kind: ContextMenuKind::Workspace { ws_idx: idx },
+ x: mouse.column,
+ y: mouse.row,
+ list: MenuListState::new(0),
+ });
+ self.mode = Mode::ContextMenu;
+ }
+ }
+
+ MouseEventKind::Down(MouseButton::Right)
+ if self.tab_at(mouse.column, mouse.row).is_some() =>
+ {
+ if let (Some(ws_idx), Some(tab_idx)) =
+ (self.active, self.tab_at(mouse.column, mouse.row))
+ {
+ self.switch_tab(tab_idx);
+ self.context_menu = Some(ContextMenuState {
+ kind: ContextMenuKind::Tab { ws_idx, tab_idx },
+ x: mouse.column,
+ y: mouse.row,
+ list: MenuListState::new(0),
+ });
+ self.mode = Mode::ContextMenu;
+ }
+ }
+
+ MouseEventKind::Down(MouseButton::Right) if !in_sidebar => {
+ if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() {
+ self.focus_pane(info.id);
+ self.context_menu = Some(ContextMenuState {
+ kind: ContextMenuKind::Pane,
+ x: mouse.column,
+ y: mouse.row,
+ list: MenuListState::new(0),
+ });
+ self.mode = Mode::ContextMenu;
+ }
+ }
+
+ _ => {}
+ }
+
+ None
+ }
+
+ pub(super) fn screen_rect(&self) -> Rect {
+ let sidebar = self.view.sidebar_rect;
+ let terminal = self.view.terminal_area;
+ let x = sidebar.x.min(terminal.x);
+ let y = sidebar.y.min(terminal.y);
+ let right = (sidebar.x + sidebar.width).max(terminal.x + terminal.width);
+ let bottom = (sidebar.y + sidebar.height).max(terminal.y + terminal.height);
+ Rect::new(x, y, right.saturating_sub(x), bottom.saturating_sub(y))
+ }
+
+ pub(crate) fn context_menu_rect(&self) -> Option<Rect> {
+ let menu = self.context_menu.as_ref()?;
+ let screen = self.screen_rect();
+ let max_item_w = menu
+ .items()
+ .iter()
+ .map(|item| item.len() as u16)
+ .max()
+ .unwrap_or(0);
+ let menu_w = (max_item_w + 4).max(14).min(screen.width.max(1));
+ let menu_h = (menu.items().len() as u16 + 2).min(screen.height.max(1));
+ let x = menu.x.min(screen.x + screen.width.saturating_sub(menu_w));
+ let y = menu.y.min(screen.y + screen.height.saturating_sub(menu_h));
+ Some(Rect::new(x, y, menu_w, menu_h))
+ }
+
+ pub(crate) fn confirm_close_rect(&self) -> Rect {
+ crate::ui::confirm_close_popup_rect(self.view.terminal_area).unwrap_or_default()
+ }
+
+ fn context_menu_item_at(&self, col: u16, row: u16) -> Option<usize> {
+ let menu_rect = self.context_menu_rect()?;
+ let inner_x = menu_rect.x + 1;
+ let inner_y = menu_rect.y + 1;
+ let inner_w = menu_rect.width.saturating_sub(2);
+ let inner_h = menu_rect.height.saturating_sub(2);
+ let item_count = self
+ .context_menu
+ .as_ref()
+ .map(|menu| menu.items().len() as u16)
+ .unwrap_or(0);
+ if col >= inner_x
+ && col < inner_x + inner_w
+ && row >= inner_y
+ && row < inner_y + inner_h.min(item_count)
+ {
+ Some((row - inner_y) as usize)
+ } else {
+ None
+ }
+ }
+
+ pub(super) fn tab_at(&self, col: u16, row: u16) -> Option<usize> {
+ self.view
+ .tab_hit_areas
+ .iter()
+ .enumerate()
+ .find_map(|(idx, area)| {
+ (area.width > 0
+ && row >= area.y
+ && row < area.y + area.height
+ && col >= area.x
+ && col < area.x + area.width)
+ .then_some(idx)
+ })
+ }
+
+ pub(super) fn on_tab_bar(&self, col: u16, row: u16) -> bool {
+ let area = self.view.tab_bar_rect;
+ area.width > 0
+ && row >= area.y
+ && row < area.y + area.height
+ && col >= area.x
+ && col < area.x + area.width
+ }
+
+ pub(super) fn on_tab_scroll_left_button(&self, col: u16, row: u16) -> bool {
+ let area = self.view.tab_scroll_left_hit_area;
+ area.width > 0
+ && row >= area.y
+ && row < area.y + area.height
+ && col >= area.x
+ && col < area.x + area.width
+ }
+
+ pub(super) fn on_tab_scroll_right_button(&self, col: u16, row: u16) -> bool {
+ let area = self.view.tab_scroll_right_hit_area;
+ area.width > 0
+ && row >= area.y
+ && row < area.y + area.height
+ && col >= area.x
+ && col < area.x + area.width
+ }
+
+ pub(super) fn tab_drop_index_at(&self, col: u16, row: u16) -> Option<usize> {
+ if !self.on_tab_bar(col, row) {
+ return None;
+ }
+
+ let visible_tabs: Vec<_> = self
+ .view
+ .tab_hit_areas
+ .iter()
+ .enumerate()
+ .filter(|(_, rect)| rect.width > 0)
+ .collect();
+ let (first_idx, first_rect) = *visible_tabs.first()?;
+ let (last_idx, last_rect) = *visible_tabs.last()?;
+
+ if self.on_tab_scroll_left_button(col, row) {
+ return Some(0);
+ }
+ if self.on_tab_scroll_right_button(col, row) {
+ return self
+ .active
+ .and_then(|idx| self.workspaces.get(idx))
+ .map(|ws| ws.tabs.len());
+ }
+
+ let left_edge = if first_idx == 0 {
+ first_rect.x
+ } else {
+ self.view.tab_scroll_left_hit_area.x + self.view.tab_scroll_left_hit_area.width
+ };
+ let right_edge = if self
+ .active
+ .and_then(|idx| self.workspaces.get(idx))
+ .is_some_and(|ws| last_idx + 1 >= ws.tabs.len())
+ {
+ last_rect.x + last_rect.width
+ } else {
+ self.view.tab_scroll_right_hit_area.x.saturating_sub(1)
+ };
+
+ if col <= left_edge {
+ return Some(first_idx);
+ }
+ if col >= right_edge {
+ return Some(last_idx + 1);
+ }
+
+ for (idx, rect) in visible_tabs {
+ let midpoint = rect.x + rect.width / 2;
+ if col < midpoint {
+ return Some(idx);
+ }
+ if col < rect.x + rect.width {
+ return Some(idx + 1);
+ }
+ }
+
+ Some(last_idx + 1)
+ }
+
+ pub(super) fn on_new_tab_button(&self, col: u16, row: u16) -> bool {
+ let area = self.view.new_tab_hit_area;
+ area.width > 0
+ && row >= area.y
+ && row < area.y + area.height
+ && col >= area.x
+ && col < area.x + area.width
+ }
+
+ pub(super) fn find_border_at(&self, col: u16, row: u16) -> Option<&SplitBorder> {
+ self.view.split_borders.iter().find(|b| match b.direction {
+ Direction::Horizontal => {
+ (col as i32 - b.pos as i32).unsigned_abs() <= 1
+ && row >= b.area.y
+ && row < b.area.y + b.area.height
+ }
+ Direction::Vertical => {
+ (row as i32 - b.pos as i32).unsigned_abs() <= 1
+ && col >= b.area.x
+ && col < b.area.x + b.area.width
+ }
+ })
+ }
+
+ pub(super) fn pane_at(&self, col: u16, row: u16) -> Option<&PaneInfo> {
+ self.view.pane_infos.iter().find(|p| {
+ col >= p.inner_rect.x
+ && col < p.inner_rect.x + p.inner_rect.width
+ && row >= p.inner_rect.y
+ && row < p.inner_rect.y + p.inner_rect.height
+ })
+ }
+
+ pub(super) fn pane_mouse_target(&self, col: u16, row: u16) -> Option<&PaneInfo> {
+ self.pane_at(col, row)
+ .or_else(|| self.pane_frame_at(col, row))
+ }
+
+ pub(super) fn pane_info_by_id(&self, pane_id: crate::layout::PaneId) -> Option<&PaneInfo> {
+ self.view.pane_infos.iter().find(|info| info.id == pane_id)
+ }
+
+ pub(super) fn pane_frame_at(&self, col: u16, row: u16) -> Option<&PaneInfo> {
+ self.view.pane_infos.iter().find(|p| {
+ col >= p.rect.x
+ && col < p.rect.x + p.rect.width
+ && row >= p.rect.y
+ && row < p.rect.y + p.rect.height
+ })
+ }
+
+ pub(super) fn focus_pane(&mut self, pane_id: crate::layout::PaneId) {
+ if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) {
+ if ws.layout.focused() != pane_id {
+ ws.layout.focus_pane(pane_id);
+ self.mark_session_dirty();
+ }
+ }
+ }
+
+ pub(super) fn scroll_pane_up(&self, pane_id: crate::layout::PaneId, lines: usize) {
+ if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) {
+ if let Some(rt) = ws.runtimes.get(&pane_id) {
+ rt.scroll_up(lines);
+ }
+ }
+ }
+
+ pub(super) fn scroll_pane_down(&self, pane_id: crate::layout::PaneId, lines: usize) {
+ if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) {
+ if let Some(rt) = ws.runtimes.get(&pane_id) {
+ rt.scroll_down(lines);
+ }
+ }
+ }
+
+ pub(super) fn pane_scroll_metrics(
+ &self,
+ pane_id: crate::layout::PaneId,
+ ) -> Option<crate::pane::ScrollMetrics> {
+ self.active
+ .and_then(|i| self.workspaces.get(i))
+ .and_then(|ws| ws.runtime(pane_id))
+ .and_then(crate::pane::PaneRuntime::scroll_metrics)
+ }
+
+ pub(super) fn handle_terminal_wheel(&mut self, mouse: MouseEvent) {
+ const LINES_PER_NOTCH: usize = 3;
+
+ if let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() {
+ self.focus_pane(info.id);
+ if self.forward_pane_wheel(&info, mouse) {
+ return;
+ }
+ match mouse.kind {
+ MouseEventKind::ScrollUp => self.scroll_pane_up(info.id, LINES_PER_NOTCH),
+ MouseEventKind::ScrollDown => self.scroll_pane_down(info.id, LINES_PER_NOTCH),
+ _ => {}
+ }
+ return;
+ }
+
+ if let Some(info) = self.pane_frame_at(mouse.column, mouse.row).cloned() {
+ self.focus_pane(info.id);
+ match mouse.kind {
+ MouseEventKind::ScrollUp => self.scroll_pane_up(info.id, LINES_PER_NOTCH),
+ MouseEventKind::ScrollDown => self.scroll_pane_down(info.id, LINES_PER_NOTCH),
+ _ => {}
+ }
+ return;
+ }
+
+ if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) {
+ if let Some(rt) = ws.focused_runtime() {
+ match mouse.kind {
+ MouseEventKind::ScrollUp => rt.scroll_up(LINES_PER_NOTCH),
+ MouseEventKind::ScrollDown => rt.scroll_down(LINES_PER_NOTCH),
+ _ => {}
+ }
+ }
+ }
+ }
+
+ pub(super) fn forward_pane_mouse_button(&self, info: &PaneInfo, mouse: MouseEvent) -> bool {
+ let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) else {
+ return false;
+ };
+ let Some(rt) = ws.runtimes.get(&info.id) else {
+ return false;
+ };
+ let column = mouse.column.saturating_sub(info.inner_rect.x);
+ let row = mouse.row.saturating_sub(info.inner_rect.y);
+ let Some(bytes) = rt.encode_mouse_button(mouse.kind, column, row, mouse.modifiers) else {
+ return false;
+ };
+ rt.scroll_reset();
+ if let Err(err) = rt.try_send_bytes(Bytes::from(bytes)) {
+ warn!(pane = info.id.raw(), err = %err, kind = ?mouse.kind, "failed to forward mouse button event");
+ }
+ true
+ }
+
+ pub(super) fn forward_pane_wheel(&self, info: &PaneInfo, mouse: MouseEvent) -> bool {
+ let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) else {
+ return false;
+ };
+ let Some(rt) = ws.runtimes.get(&info.id) else {
+ return false;
+ };
+ match rt.wheel_routing() {
+ Some(crate::pane::WheelRouting::HostScroll) | None => false,
+ Some(crate::pane::WheelRouting::MouseReport) => {
+ rt.scroll_reset();
+ let column = mouse.column.saturating_sub(info.inner_rect.x);
+ let row = mouse.row.saturating_sub(info.inner_rect.y);
+ let Some(bytes) = rt.encode_mouse_wheel(mouse.kind, column, row, mouse.modifiers)
+ else {
+ warn!(pane = info.id.raw(), kind = ?mouse.kind, "failed to encode mouse wheel event");
+ return true;
+ };
+ if let Err(err) = rt.try_send_bytes(Bytes::from(bytes)) {
+ warn!(pane = info.id.raw(), err = %err, "failed to forward mouse wheel event");
+ }
+ true
+ }
+ Some(crate::pane::WheelRouting::AlternateScroll) => {
+ rt.scroll_reset();
+ let Some(bytes) = rt.encode_alternate_scroll(mouse.kind) else {
+ return true;
+ };
+ if let Err(err) = rt.try_send_bytes(Bytes::from(bytes)) {
+ warn!(pane = info.id.raw(), err = %err, "failed to forward alternate-scroll key");
+ }
+ true
+ }
+ }
+ }
+
+ pub(super) fn set_pane_scroll_offset(
+ &self,
+ pane_id: crate::layout::PaneId,
+ offset_from_bottom: usize,
+ ) {
+ if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) {
+ if let Some(rt) = ws.runtimes.get(&pane_id) {
+ rt.set_scroll_offset_from_bottom(offset_from_bottom);
+ }
+ }
+ }
+
+ pub(super) fn scrollbar_target_at(
+ &self,
+ col: u16,
+ row: u16,
+ ) -> Option<(crate::layout::PaneId, ScrollbarClickTarget)> {
+ let ws = self.active.and_then(|i| self.workspaces.get(i))?;
+ let info = self.view.pane_infos.iter().find(|info| {
+ crate::ui::pane_scrollbar_rect(info).is_some_and(|track| {
+ col >= track.x
+ && col < track.x + track.width
+ && row >= track.y
+ && row < track.y + track.height
+ })
+ })?;
+ let rt = ws.runtimes.get(&info.id)?;
+ let metrics = rt.scroll_metrics()?;
+ if metrics.max_offset_from_bottom == 0 {
+ return None;
+ }
+ let track = crate::ui::pane_scrollbar_rect(info)?;
+ if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) {
+ Some((info.id, ScrollbarClickTarget::Thumb { grab_row_offset }))
+ } else {
+ Some((
+ info.id,
+ ScrollbarClickTarget::Track {
+ offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row),
+ },
+ ))
+ }
+ }
+
+ pub(super) fn scrollbar_offset_for_pane_row(
+ &self,
+ pane_id: crate::layout::PaneId,
+ row: u16,
+ grab_row_offset: u16,
+ ) -> Option<usize> {
+ let ws = self.active.and_then(|i| self.workspaces.get(i))?;
+ let info = self
+ .view
+ .pane_infos
+ .iter()
+ .find(|info| info.id == pane_id)?;
+ let track = crate::ui::pane_scrollbar_rect(info)?;
+ let rt = ws.runtimes.get(&pane_id)?;
+ let metrics = rt.scroll_metrics()?;
+ if metrics.max_offset_from_bottom == 0 {
+ return None;
+ }
+ Some(crate::ui::scrollbar_offset_from_drag_row(
+ metrics,
+ track,
+ row,
+ grab_row_offset,
+ ))
+ }
+}
+
+#[cfg(test)]
+pub(super) fn wheel_routing(input_state: crate::pane::InputState) -> WheelRouting {
+ if input_state.mouse_protocol_mode.reporting_enabled() {
+ WheelRouting::MouseReport
+ } else if input_state.alternate_screen && input_state.mouse_alternate_scroll {
+ WheelRouting::AlternateScroll
+ } else {
+ WheelRouting::HostScroll
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEventKind};
+ use ratatui::layout::{Direction, Rect};
+
+ use super::super::{
+ app_for_mouse_test, capture_snapshot, handle_context_menu_key, mouse, root_layout_ratio,
+ };
+ use super::*;
+ use crate::{
+ app::state::{ContextMenuKind, ContextMenuState, MenuListState, Mode},
+ workspace::Workspace,
+ };
+
+ #[test]
+ fn hovering_context_menu_updates_highlight() {
+ let mut app = app_for_mouse_test();
+ app.state.context_menu = Some(ContextMenuState {
+ kind: ContextMenuKind::Workspace { ws_idx: 0 },
+ x: 2,
+ y: 2,
+ list: MenuListState::new(0),
+ });
+ app.state.mode = Mode::ContextMenu;
+
+ let menu = app.state.context_menu_rect().unwrap();
+ app.handle_mouse(mouse(MouseEventKind::Moved, menu.x + 2, menu.y + 2));
+
+ assert_eq!(app.state.context_menu.unwrap().list.highlighted, 1);
+ }
+
+ #[test]
+ fn clicking_confirm_close_accepts_workspace_close() {
+ let mut app = app_for_mouse_test();
+ app.state.workspaces = vec![Workspace::test_new("a"), Workspace::test_new("b")];
+ app.state.active = Some(0);
+ app.state.selected = 1;
+ app.state.mode = Mode::ConfirmClose;
+
+ let popup = app.state.confirm_close_rect();
+ let inner = Rect::new(
+ popup.x + 1,
+ popup.y + 1,
+ popup.width.saturating_sub(2),
+ popup.height.saturating_sub(2),
+ );
+ let (confirm, _) = crate::ui::confirm_close_button_rects(inner);
+
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ confirm.x,
+ confirm.y,
+ ));
+
+ assert_eq!(app.state.workspaces.len(), 1);
+ assert_eq!(app.state.mode, Mode::Terminal);
+ }
+
+ #[test]
+ fn clicking_confirm_close_accepts_after_workspace_context_menu_close() {
+ let mut app = app_for_mouse_test();
+ app.state.workspaces = vec![Workspace::test_new("a"), Workspace::test_new("b")];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+
+ app.state.context_menu = Some(ContextMenuState {
+ kind: ContextMenuKind::Workspace { ws_idx: 1 },
+ x: 2,
+ y: 2,
+ list: MenuListState::new(1),
+ });
+ app.state.mode = Mode::ContextMenu;
+ handle_context_menu_key(
+ &mut app.state,
+ KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
+ );
+ assert_eq!(app.state.mode, Mode::ConfirmClose);
+ assert_eq!(app.state.selected, 1);
+
+ let popup = app.state.confirm_close_rect();
+ let inner = Rect::new(
+ popup.x + 1,
+ popup.y + 1,
+ popup.width.saturating_sub(2),
+ popup.height.saturating_sub(2),
+ );
+ let (confirm, _) = crate::ui::confirm_close_button_rects(inner);
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ confirm.x + 1,
+ confirm.y,
+ ));
+
+ assert_eq!(app.state.workspaces.len(), 1);
+ assert_eq!(app.state.workspaces[0].display_name(), "a");
+ }
+
+ #[test]
+ fn dragging_pane_split_updates_captured_layout_ratio() {
+ let mut app = app_for_mouse_test();
+ app.state.workspaces = vec![Workspace::test_new("test")];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.workspaces[0].test_split(Direction::Horizontal);
+ crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20));
+ let border = app.state.view.split_borders[0].clone();
+ let before = capture_snapshot(&app.state);
+ let drag_row = border.area.y.saturating_add(1);
+
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ border.pos,
+ drag_row,
+ ));
+ app.handle_mouse(mouse(
+ MouseEventKind::Drag(MouseButton::Left),
+ border.pos.saturating_add(6),
+ drag_row,
+ ));
+
+ let after = capture_snapshot(&app.state);
+ assert_ne!(root_layout_ratio(&before), root_layout_ratio(&after));
+ }
+
+ #[test]
+ fn wheel_routing_prefers_mouse_reporting() {
+ let input_state = crate::pane::InputState {
+ alternate_screen: true,
+ application_cursor: false,
+ bracketed_paste: false,
+ focus_reporting: false,
+ mouse_protocol_mode: crate::input::MouseProtocolMode::ButtonMotion,
+ mouse_protocol_encoding: crate::input::MouseProtocolEncoding::Sgr,
+ mouse_alternate_scroll: true,
+ };
+
+ assert_eq!(wheel_routing(input_state), WheelRouting::MouseReport);
+ }
+
+ #[test]
+ fn wheel_routing_uses_alternate_scroll_in_fullscreen_without_mouse_reporting() {
+ let input_state = crate::pane::InputState {
+ alternate_screen: true,
+ application_cursor: false,
+ bracketed_paste: false,
+ focus_reporting: false,
+ mouse_protocol_mode: crate::input::MouseProtocolMode::None,
+ mouse_protocol_encoding: crate::input::MouseProtocolEncoding::Default,
+ mouse_alternate_scroll: true,
+ };
+
+ assert_eq!(wheel_routing(input_state), WheelRouting::AlternateScroll);
+ }
+
+ #[test]
+ fn wheel_routing_falls_back_to_host_scrollback() {
+ let input_state = crate::pane::InputState {
+ alternate_screen: false,
+ application_cursor: false,
+ bracketed_paste: false,
+ focus_reporting: false,
+ mouse_protocol_mode: crate::input::MouseProtocolMode::None,
+ mouse_protocol_encoding: crate::input::MouseProtocolEncoding::Default,
+ mouse_alternate_scroll: true,
+ };
+
+ assert_eq!(wheel_routing(input_state), WheelRouting::HostScroll);
+ }
+}