use std::time::Instant; use zellij_utils::data::{Direction, InputMode, Resize, ResizeStrategy}; use zellij_utils::errors::prelude::*; use zellij_utils::input::mouse::{MouseEvent, MouseEventType}; use zellij_utils::pane_size::PaneGeom; use zellij_utils::position::Position; use crate::background_jobs::BackgroundJob; use crate::panes::grid::Click; use crate::panes::PaneId; use crate::plugins::PluginInstruction; use crate::screen::{GuestModalOutcome, ScreenInstruction}; use crate::ServerInstruction; use crate::ClientId; use super::{Pane, Tab}; /// Track repeated presses on a pane frame, returning true when this press is /// the second click of a double-click. /// /// Only PRESS events touch the tracker. This is the whole subtlety: a /// double-click is press/release/press/release, so treating the intervening /// RELEASE as "something other than a frame press" and resetting there means /// the count never reaches 2 and no double-click is ever detected. A press /// somewhere that is not a frame does legitimately break the sequence. fn record_frame_click(tracker: &mut Click, event: &MouseEvent, on_frame: bool) -> bool { let is_plain_left_press = event.left && event.event_type == MouseEventType::Press && !event.ctrl && !event.alt; if !is_plain_left_press { // release / drag / scroll are not part of the decision and must leave // the tracker alone return false; } if !on_frame { tracker.reset(); return false; } tracker.record_click(event.position); tracker.is_double_click() } fn clear_hover_for_client(tab: &mut Tab, client_id: ClientId) -> bool { let mut cleared = false; if let Some(prev_pid) = tab.mouse_hover_pane_id.remove(&client_id) { if let Some(pane) = tab.get_pane_with_id_mut(prev_pid) { pane.set_hover_position(None); } cleared = true; } if let Some(prev_plugin_pid) = tab.plugin_hover_pane_id.remove(&client_id) { if let Some(pane) = tab.get_pane_with_id(prev_plugin_pid) { let _ = pane.mouse_event( &MouseEvent::new_buttonless_motion(Position::new(0, u16::MAX)), client_id, ); } cleared = true; } cleared } #[derive(Debug, Default, Copy, Clone)] pub struct MouseEffect { pub state_changed: bool, pub leave_clipboard_message: bool, pub group_toggle: Option, pub group_add: Option, pub ungroup: bool, } impl MouseEffect { pub fn state_changed() -> Self { MouseEffect { state_changed: true, leave_clipboard_message: false, group_toggle: None, group_add: None, ungroup: false, } } pub fn leave_clipboard_message() -> Self { MouseEffect { state_changed: false, leave_clipboard_message: true, group_toggle: None, group_add: None, ungroup: false, } } pub fn state_changed_and_leave_clipboard_message() -> Self { MouseEffect { state_changed: true, leave_clipboard_message: true, group_toggle: None, group_add: None, ungroup: false, } } pub fn group_toggle(pane_id: PaneId) -> Self { MouseEffect { state_changed: true, leave_clipboard_message: false, group_toggle: Some(pane_id), group_add: None, ungroup: false, } } pub fn group_add(pane_id: PaneId) -> Self { MouseEffect { state_changed: true, leave_clipboard_message: false, group_toggle: None, group_add: Some(pane_id), ungroup: false, } } pub fn ungroup() -> Self { MouseEffect { state_changed: true, leave_clipboard_message: false, group_toggle: None, group_add: None, ungroup: true, } } } #[derive(Debug, Clone, PartialEq)] enum MouseAction { /// double-click on a pane's title row -> start renaming it StartRenamingPane { pane_id: PaneId, }, /// press on a tiled pane's title bar -> begin dragging it to rearrange StartDraggingTiledPane { pane_id: PaneId, }, /// release while dragging -> swap with the pane under the cursor DropDraggedTiledPane { position: Position, }, /// while dragging -> show what the reordering would look like by actually /// performing the swap; undone if the drag moves elsewhere PreviewDragSwap { target: Option, }, GroupToggle(PaneId), GroupAdd(PaneId), Ungroup, StartResize { pane_id: PaneId, edge: PaneEdge, is_floating: bool, position: Position, }, ContinueResize { position: Position, }, StopResize { position: Position, }, FocusPane { pane_id: PaneId, position: Position, }, FocusPaneAndClickThrough { pane_id: PaneId, position: Position, event: MouseEvent, }, ShowFloatingPanesAndFocus { pane_id: PaneId, }, StartSelection { pane_id: PaneId, position: Position, }, UpdateSelection { position: Position, }, EndSelection { position: Position, }, StartMovingFloatingPane { position: Position, }, ContinueMovingFloatingPane { position: Position, }, StopMovingFloatingPane { position: Position, }, ScrollUp { pane_id: PaneId, lines: usize, }, ScrollDown { pane_id: PaneId, lines: usize, }, ScrollLeft { pane_id: PaneId, cols: usize, }, ScrollRight { pane_id: PaneId, cols: usize, }, ResizeScrollUp { pane_id: PaneId, }, ResizeScrollDown { pane_id: PaneId, }, UpdateHover { pane_id: Option, position: Option, }, FocusOnHover { pane_id: PaneId, position: Position, }, SendToTerminal { pane_id: PaneId, event: MouseEvent, }, FrameIntercepted { pane_id: PaneId, }, NoAction, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PaneEdge { Left, Right, Top, Bottom, TopLeft, TopRight, BottomLeft, BottomRight, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct PaneResizeState { pub pane_id: PaneId, pub edge: PaneEdge, pub start_position: Position, pub start_geom: PaneGeom, pub is_floating: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct ClickedPaneDetails { pane_id: PaneId, on_frame: bool, /// on the top border specifically, i.e. the row the title is drawn on on_title: bool, /// on the title row EXCLUDING the corners: the drag-to-rearrange handle on_title_move_handle: bool, frame_intercepted: bool, edge: Option, is_floating: bool, terminal_wants_mouse: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct MouseEventContext { pane_id_at_position: Option, active_pane_id: Option, floating_visible: bool, pane_being_resized: bool, selecting_with_mouse: bool, pane_being_moved: bool, clicked_pane: Option, /// this press is the 2nd click of a double-click on a pane frame frame_double_click: bool, /// tiled pane this client is dragging by its title bar, if any tiled_pane_being_dragged: Option, pinned_selectable: Option, pinned_unselectable: Option, focus_follows_mouse: bool, mouse_click_through: bool, mouse_scroll_resize: bool, } fn edge_and_delta_to_strategies( edge: PaneEdge, delta_x: isize, delta_y: isize, ) -> Vec { use Direction::*; use Resize::*; match edge { PaneEdge::Left => { let resize = if delta_x < 0 { Increase } else { Decrease }; vec![ResizeStrategy { resize, direction: Some(Left), invert_on_boundaries: false, }] }, PaneEdge::Right => { let resize = if delta_x > 0 { Increase } else { Decrease }; vec![ResizeStrategy { resize, direction: Some(Right), invert_on_boundaries: false, }] }, PaneEdge::Top => { let resize = if delta_y < 0 { Increase } else { Decrease }; vec![ResizeStrategy { resize, direction: Some(Up), invert_on_boundaries: false, }] }, PaneEdge::Bottom => { let resize = if delta_y > 0 { Increase } else { Decrease }; vec![ResizeStrategy { resize, direction: Some(Down), invert_on_boundaries: false, }] }, PaneEdge::TopLeft => { let mut strategies = vec![]; let resize_y = if delta_y < 0 { Increase } else { Decrease }; strategies.push(ResizeStrategy { resize: resize_y, direction: Some(Up), invert_on_boundaries: false, }); let resize_x = if delta_x < 0 { Increase } else { Decrease }; strategies.push(ResizeStrategy { resize: resize_x, direction: Some(Left), invert_on_boundaries: false, }); strategies }, PaneEdge::TopRight => { let mut strategies = vec![]; let resize_y = if delta_y < 0 { Increase } else { Decrease }; strategies.push(ResizeStrategy { resize: resize_y, direction: Some(Up), invert_on_boundaries: false, }); let resize_x = if delta_x > 0 { Increase } else { Decrease }; strategies.push(ResizeStrategy { resize: resize_x, direction: Some(Right), invert_on_boundaries: false, }); strategies }, PaneEdge::BottomLeft => { let mut strategies = vec![]; let resize_y = if delta_y > 0 { Increase } else { Decrease }; strategies.push(ResizeStrategy { resize: resize_y, direction: Some(Down), invert_on_boundaries: false, }); let resize_x = if delta_x < 0 { Increase } else { Decrease }; strategies.push(ResizeStrategy { resize: resize_x, direction: Some(Left), invert_on_boundaries: false, }); strategies }, PaneEdge::BottomRight => { let mut strategies = vec![]; let resize_y = if delta_y > 0 { Increase } else { Decrease }; strategies.push(ResizeStrategy { resize: resize_y, direction: Some(Down), invert_on_boundaries: false, }); let resize_x = if delta_x > 0 { Increase } else { Decrease }; strategies.push(ResizeStrategy { resize: resize_x, direction: Some(Right), invert_on_boundaries: false, }); strategies }, } } pub struct MouseHandler; impl MouseHandler { pub(crate) fn handle_mouse_event( tab: &mut Tab, event: &MouseEvent, client_id: ClientId, ) -> Result { if let Some(effect) = Self::intercept_guest_modal_mouse_event(tab, event, client_id)? { return Ok(effect); } if let Some(effect) = Self::intercept_stack_list_indicator_click(tab, event)? { return Ok(effect); } if let Some(effect) = Self::intercept_status_bar_button_click(tab, event)? { return Ok(effect); } // Not an intercept: hover state is updated and the event still goes on to // its normal handling. let hover_changed = Self::update_status_bar_hover(tab, event, client_id)?; let context = Self::gather_mouse_event_context(tab, event, client_id)?; let action = Self::determine_mouse_action(event, &context)?; let mut effect = Self::execute_mouse_action(tab, action, event, client_id)?; if hover_changed { effect.state_changed = true; } Ok(effect) } /// Track which status bar button the pointer is over, so it can be drawn /// highlighted. /// /// Done here rather than in the hover action, because motion over the ACTIVE /// pane is dispatched as SendToTerminal, not UpdateHover -- and the pane you /// are about to click a button in is usually the one you are already in. /// /// Returns whether anything changed, so the caller can force a render. fn update_status_bar_hover( tab: &mut Tab, event: &MouseEvent, client_id: ClientId, ) -> Result { if event.event_type != MouseEventType::Motion { return Ok(false); } let hovered = Self::get_pane_at(tab, &event.position, false)? .and_then(|pane| pane.status_bar_button_at(&event.position).map(|id| (pane.pid(), id))); let mut changed = false; // clear every other pane's hover for this client, so moving between panes // cannot leave a button lit behind us let pane_ids: Vec = tab.get_all_pane_ids(); for pane_id in pane_ids { let wanted = match &hovered { Some((hovered_id, button_id)) if *hovered_id == pane_id => Some(button_id.clone()), _ => None, }; if let Some(pane) = tab.get_pane_with_id_mut(pane_id) { changed |= pane.set_hovered_status_bar_button(client_id, wanted); } } Ok(changed) } /// A click on a pane's status bar button reports it to the program in that /// pane. /// /// Intercepted before normal handling: the bottom frame row otherwise belongs /// to border-drag resizing, and a button click there should not also start a /// resize. /// /// The report is a FIXED format carrying only the button id, never bytes the /// payload chose. The sequence that created the button arrives in-band and is /// untrusted -- `cat` of a hostile file emits it just as readily -- so if a /// click could send arbitrary bytes, viewing a file would be able to inject /// into the stdin of whatever is running, a shell included. fn intercept_status_bar_button_click( tab: &mut Tab, event: &MouseEvent, ) -> Result> { if !(event.left && event.event_type == MouseEventType::Press) { return Ok(None); } let Some(pane) = Self::get_pane_at(tab, &event.position, false)? else { return Ok(None); }; let pane_id = pane.pid(); let Some(button_id) = pane.status_bar_button_at(&event.position) else { return Ok(None); }; let report = format!("\u{1b}]5577;1;click;{}\u{1b}\\", button_id); tab.write_to_pane_id_without_preprocessing(report.into_bytes(), pane_id)?; Ok(Some(MouseEffect::state_changed())) } /// Clicking the picker's "+N more" / "- fewer" row toggles the stack's /// expansion. /// /// Intercepted before normal handling because that row is not a member: it /// resolves to no pane, so letting it fall through would either do nothing /// or act on the pane underneath. Deliberately a click rather than hover, /// since expanding changes the reserved row count and therefore resizes the /// visible pane's PTY. fn intercept_stack_list_indicator_click( tab: &mut Tab, event: &MouseEvent, ) -> Result> { if !(event.left && event.event_type == MouseEventType::Press) { return Ok(None); } let Some(pane_id) = Self::get_pane_at(tab, &event.position, false)?.map(|p| p.pid()) else { return Ok(None); }; if tab.toggle_stack_list_expansion_at_point(pane_id, &event.position) { Ok(Some(MouseEffect::state_changed())) } else { Ok(None) } } fn intercept_guest_modal_mouse_event( tab: &mut Tab, event: &MouseEvent, client_id: ClientId, ) -> Result> { let pane_id_at_position = Self::get_pane_at(tab, &event.position, false)?.map(|p| p.pid()); let pane_id = match pane_id_at_position { Some(pane_id) => pane_id, None => return Ok(None), }; if !tab.pane_has_guest_modal_for_client(pane_id, client_id) { return Ok(None); } let hit_option = if event.event_type == MouseEventType::Release && event.left { let style = tab.style; if let Some(pane) = tab.get_pane_with_id(pane_id) { let relative_position = pane.relative_position(&event.position); let rows = pane.get_content_rows(); let columns = pane.get_content_columns(); let row = relative_position.line(); if row < 0 { None } else { let session_name = pane.guest_session_name().unwrap_or_default(); let selection = pane.guest_modal_selection(client_id).unwrap_or(0); let shortcuts = pane.guest_modal_shortcuts(); crate::panes::nested_session_modal::guest_modal_option_at_content_row( rows, columns, row as usize, &style, &session_name, selection, &shortcuts, ) } } else { None } } else { None }; if let Some(option) = hit_option { let outcome = match option { 0 => GuestModalOutcome::Zoom, _ => GuestModalOutcome::Descend, }; let _ = tab .senders .send_to_screen(ScreenInstruction::GuestModalChoice { client_id, pane_id, outcome, }); } Ok(Some(MouseEffect::state_changed())) } fn gather_mouse_event_context( tab: &mut Tab, event: &MouseEvent, client_id: ClientId, ) -> Result { let err_context = || format!("failed to gather context for event {event:?}"); let pane_id_at_position = Self::get_pane_at(tab, &event.position, false) .with_context(err_context)? .map(|p| p.pid()); let active_pane_id = tab.get_active_pane_id(client_id); let floating_visible = tab.floating_panes.panes_are_visible(); let clicked_pane = pane_id_at_position.and_then(|id| { Self::gather_clicked_pane_details( tab, id, &event.position, active_pane_id, event, client_id, ) }); let (pinned_selectable, pinned_unselectable) = if !floating_visible { let selectable = tab .floating_panes .get_pinned_pane_id_at(&event.position, true) .ok() .flatten(); let unselectable = tab .floating_panes .get_pinned_pane_id_at(&event.position, false) .ok() .flatten(); (selectable, unselectable) } else { (None, None) }; let frame_double_click = record_frame_click( tab.frame_clicks.entry(client_id).or_default(), event, clicked_pane.map(|d| d.on_frame).unwrap_or(false), ); Ok(MouseEventContext { pane_id_at_position, active_pane_id, floating_visible, pane_being_resized: tab.pane_being_resized_with_mouse.is_some(), selecting_with_mouse: tab.selecting_with_mouse_in_pane.is_some(), pane_being_moved: tab.floating_panes.pane_is_being_moved_with_mouse(), clicked_pane, frame_double_click, tiled_pane_being_dragged: tab.tiled_pane_being_dragged.get(&client_id).copied(), pinned_selectable, pinned_unselectable, // suppressed while typing into a rename/search prompt, so hovering // does not drag the prompt onto another pane focus_follows_mouse: tab.focus_follows_mouse && !tab.client_is_in_text_entry_mode(client_id), mouse_click_through: tab.mouse_click_through, mouse_scroll_resize: tab.mouse_scroll_resize, }) } fn gather_clicked_pane_details( tab: &mut Tab, pane_id: PaneId, position: &Position, active_pane_id: Option, event: &MouseEvent, client_id: ClientId, ) -> Option { let is_floating = tab.floating_panes.panes_contain(&pane_id); let is_hidden_stack_list_member = tab.pane_is_hidden_stack_list_member(&pane_id); let pane = Self::get_pane_at(tab, position, false).ok()??; let on_frame = !is_hidden_stack_list_member && pane.position_is_on_frame(position); let on_title = on_frame && pane.position_is_on_title_row(position); // Stacked panes are excluded from the drag handle: in a stack the title // row is the affordance for focusing/expanding a member, and hijacking // it for dragging breaks stack navigation by mouse. Rearranging a // stacked pane by dragging is not meaningful anyway. let pane_is_stacked = pane.current_geom().is_stacked(); let on_title_move_handle = on_title && !pane_is_stacked && pane.position_is_on_title_move_handle(position); let frame_intercepted = on_frame && pane.intercept_mouse_event_on_frame(event, client_id); let edge = if on_frame { pane.get_edge_at_position(position) } else { None }; let terminal_wants_mouse = if Some(pane_id) == active_pane_id { let relative_position = pane.relative_position(position); pane.mouse_left_click(&relative_position, false).is_some() } else { false }; Some(ClickedPaneDetails { pane_id, on_frame, on_title, on_title_move_handle, frame_intercepted, edge, is_floating, terminal_wants_mouse, }) } fn start_pane_resize_with_mouse( tab: &mut Tab, pane_id: PaneId, edge: PaneEdge, position: Position, _client_id: ClientId, ) -> Result<()> { let err_context = || format!("failed to start pane resize for pane {pane_id:?}"); let is_floating = tab.floating_panes.panes_contain(&pane_id); let start_geom = if is_floating { tab.floating_panes .get_pane(pane_id) .map(|p| p.position_and_size()) .with_context(err_context)? } else { tab.tiled_panes .get_pane(pane_id) .map(|p| p.position_and_size()) .with_context(err_context)? }; tab.pane_being_resized_with_mouse = Some(PaneResizeState { pane_id, edge, start_position: position, start_geom, is_floating, }); Ok(()) } fn continue_pane_resize_with_mouse( tab: &mut Tab, current_position: Position, _client_id: ClientId, ) -> Result { let err_context = || "failed to continue pane resize with mouse"; let (pane_id, edge, is_floating, delta_x, delta_y) = if let Some(resize_state) = &tab.pane_being_resized_with_mouse { let delta_x = current_position.column() as isize - resize_state.start_position.column() as isize; let delta_y = current_position.line() - resize_state.start_position.line(); if delta_x == 0 && delta_y == 0 { return Ok(false); } ( resize_state.pane_id, resize_state.edge, resize_state.is_floating, delta_x, delta_y, ) } else { return Ok(true); }; let strategies = edge_and_delta_to_strategies(edge, delta_x, delta_y); if is_floating { Self::resize_floating_pane_with_strategies( tab, pane_id, &strategies, (delta_x.unsigned_abs(), delta_y.unsigned_abs()), ) .with_context(err_context)?; } else { Self::resize_tiled_pane_with_strategies( tab, pane_id, &strategies, (delta_x.abs() as f64, delta_y.abs() as f64), ) .with_context(err_context)?; } if let Some(resize_state) = tab.pane_being_resized_with_mouse.as_mut() { resize_state.start_position = current_position; } tab.set_force_render(); Ok(true) } fn stop_pane_resize_with_mouse( tab: &mut Tab, final_position: Position, client_id: ClientId, ) -> Result { let err_context = || "failed to stop pane resize with mouse"; let start_geom = tab .pane_being_resized_with_mouse .as_ref() .map(|p| p.start_geom.clone()); let pane_id = tab .pane_being_resized_with_mouse .as_ref() .map(|p| p.pane_id); let _resized = Self::continue_pane_resize_with_mouse(tab, final_position, client_id) .with_context(err_context)?; let last_geom = pane_id .and_then(|pane_id| tab.get_pane_with_id(pane_id)) .map(|p| p.position_and_size()); let never_resized = match (start_geom, last_geom) { (Some(start_geom), Some(last_geom)) => start_geom == last_geom, _ => false, }; tab.pane_being_resized_with_mouse = None; Ok(never_resized) } fn resize_floating_pane_with_strategies( tab: &mut Tab, pane_id: PaneId, strategies: &[ResizeStrategy], change_by: (usize, usize), ) -> Result<()> { let err_context = || format!("failed to resize floating pane {pane_id:?}"); tab.floating_panes .resize_pane_with_strategies(pane_id, strategies, change_by) .with_context(err_context)?; tab.swap_layouts.set_is_floating_damaged(); Ok(()) } fn resize_tiled_pane_with_strategies( tab: &mut Tab, pane_id: PaneId, strategies: &[ResizeStrategy], change_by: (f64, f64), ) -> Result<()> { let err_context = || format!("failed to resize tiled pane {pane_id:?}"); let viewport = tab.viewport.borrow(); let viewport_cols = viewport.cols; let viewport_rows = viewport.rows; let change_by_percent = ( if viewport_cols > 0 { (change_by.0 / viewport_cols as f64) * 100.0 } else { 0.0 }, if viewport_rows > 0 { (change_by.1 / viewport_rows as f64) * 100.0 } else { 0.0 }, ); tab.tiled_panes .resize_pane_with_strategies(pane_id, strategies, change_by_percent) .with_context(err_context)?; tab.swap_layouts.set_is_tiled_damaged(); Ok(()) } fn resize_tiled_pane_with_stacked_resize( tab: &mut Tab, pane_id: PaneId, strategy: &ResizeStrategy, ) -> Result<()> { let err_context = || format!("failed to resize tiled pane {pane_id:?}"); tab.tiled_panes .stacked_resize_pane_with_id(pane_id, strategy, Some((5.0, 5.0))) .with_context(err_context)?; tab.swap_layouts.set_is_tiled_damaged(); Ok(()) } fn execute_mouse_action( tab: &mut Tab, action: MouseAction, event: &MouseEvent, client_id: ClientId, ) -> Result { let err_context = || format!("failed to execute mouse action {action:?} for client {client_id}"); let preserves_help_text = matches!(&action, MouseAction::UpdateHover { .. }) || matches!( &action, MouseAction::SendToTerminal { event, .. } if event.event_type == MouseEventType::Motion ); if !preserves_help_text { tab.mouse_help_text_visible.remove(&client_id); } match action { MouseAction::GroupToggle(pane_id) => { if let Some(pane) = tab.get_pane_with_id_mut(pane_id) { let relative_position = pane.relative_position(&event.position); if let Some((hit_plugin_id, pattern, matched_string, context)) = pane.plugin_highlight_at(&relative_position) { let _ = tab .senders .send_to_plugin(PluginInstruction::HighlightClicked { plugin_id: hit_plugin_id, client_id, pane_id, pattern, matched_string, context, }); return Ok(MouseEffect::state_changed()); } } Ok(MouseEffect::group_toggle(pane_id)) }, MouseAction::StartRenamingPane { pane_id } => { // Focus the pane first: RenamePane mode acts on the ACTIVE pane // (Screen::change_mode calls store_pane_name() on it), so // renaming an unfocused pane would otherwise rename the wrong // one. Then ask Screen to switch this client into RenamePane, // which is the same path the keybind takes. clear_hover_for_client(tab, client_id); tab.focus_pane_with_id(pane_id, false, false, client_id) .with_context(err_context)?; // Go through the SERVER, exactly as Action::SwitchToMode does // for the rename keybind. Sending ScreenInstruction::ChangeMode // directly only changes what Screen renders: the server keeps // its own `current_input_modes` map, which is what routes key // input, so bypassing it leaves the UI in RenamePane while keys // are still interpreted in the previous mode - the prompt // appears, and Enter/Esc never leave it. // // The server also derives base_mode from the session config, so // this fixes the return path too. tab.senders .send_to_server(ServerInstruction::ChangeMode( client_id, InputMode::RenamePane, None, )) .with_context(err_context)?; Ok(MouseEffect::state_changed()) }, MouseAction::StartDraggingTiledPane { pane_id } => { clear_hover_for_client(tab, client_id); // Pressing a title bar used to start a resize, which focused // the pane as a side effect. Keep click-to-focus working: a // plain click on the title is now a zero-distance drag, and // without this it would do nothing at all. tab.focus_pane_with_id(pane_id, false, false, client_id) .with_context(err_context)?; tab.tiled_pane_being_dragged.insert(client_id, pane_id); Ok(MouseEffect::state_changed()) }, MouseAction::PreviewDragSwap { target } => { let Some(dragged) = tab.tiled_pane_being_dragged.get(&client_id).copied() else { return Ok(MouseEffect::default()); }; let current_preview = tab.drag_preview_swapped_with.get(&client_id).copied(); // Nothing to do when the cursor is over the dragged pane itself // (wherever the preview has moved it to) or over the pane it is // currently swapped with. Both mean "the layout on screen is // already what dropping here would give you". Re-applying would // undo and redo the same swap on every motion event. let hovering_current_preview = match (target, current_preview) { (Some(t), Some(p)) => t == p, _ => false, }; if target == Some(dragged) || hovering_current_preview { return Ok(MouseEffect::default()); } if target == current_preview { return Ok(MouseEffect::default()); } // undo whatever is currently previewed, then preview the new one if let Some(previous) = current_preview { tab.tiled_panes.swap_panes(dragged, previous); tab.drag_preview_swapped_with.remove(&client_id); } if let Some(new_target) = target { tab.tiled_panes.swap_panes(dragged, new_target); tab.drag_preview_swapped_with.insert(client_id, new_target); } // keep the pane being dragged highlighted so it stays // identifiable as the layout moves around it tab.mouse_hover_pane_id.insert(client_id, dragged); Ok(MouseEffect::state_changed()) }, MouseAction::DropDraggedTiledPane { position: _ } => { // Nothing to apply: the preview WAS the swap, so releasing just // keeps what is already on screen. A drag that never left the // source pane has no preview, so a plain click and a // double-click still do nothing here. clear_hover_for_client(tab, client_id); let was_dragging = tab.tiled_pane_being_dragged.remove(&client_id).is_some(); let had_preview = tab.drag_preview_swapped_with.remove(&client_id).is_some(); if was_dragging && had_preview { Ok(MouseEffect::state_changed()) } else { Ok(MouseEffect::default()) } }, MouseAction::GroupAdd(pane_id) => Ok(MouseEffect::group_add(pane_id)), MouseAction::Ungroup => Ok(MouseEffect::ungroup()), MouseAction::StartResize { pane_id, edge, is_floating: _, position, } => { clear_hover_for_client(tab, client_id); Self::start_pane_resize_with_mouse(tab, pane_id, edge, position, client_id) .with_context(err_context)?; Ok(MouseEffect::state_changed()) }, MouseAction::ContinueResize { position } => { let state_changed = Self::continue_pane_resize_with_mouse(tab, position, client_id) .with_context(err_context)?; if state_changed { Ok(MouseEffect::state_changed()) } else { Ok(MouseEffect::default()) } }, MouseAction::StopResize { position } => { Self::execute_stop_resize(tab, position, client_id) }, MouseAction::FocusPane { pane_id: _, position, } => Self::execute_focus_pane(tab, position, client_id), MouseAction::FocusPaneAndClickThrough { pane_id: _, position, event: click_event, } => Self::execute_focus_pane_and_click_through(tab, position, click_event, client_id), MouseAction::ShowFloatingPanesAndFocus { pane_id } => { tab.show_floating_panes(); tab.floating_panes.focus_pane(pane_id, client_id); Ok(MouseEffect::state_changed()) }, MouseAction::StartSelection { pane_id, position } => { let pane = tab .get_pane_with_id_mut(pane_id) .ok_or_else(|| anyhow!("Failed to find pane {pane_id:?}"))?; let relative_position = pane.relative_position(&position); let mut leave_clipboard_message = false; pane.start_selection(&relative_position, client_id); if pane.get_selected_text(client_id).is_some() { leave_clipboard_message = true; } if pane.supports_mouse_selection() { tab.selecting_with_mouse_in_pane = Some(pane_id); } if leave_clipboard_message { Ok(MouseEffect::leave_clipboard_message()) } else { Ok(MouseEffect::default()) } }, MouseAction::UpdateSelection { position } => { if let Some(pane_id_with_selection) = tab.selecting_with_mouse_in_pane { if let Some(pane_with_selection) = tab.get_pane_with_id_mut(pane_id_with_selection) { let relative_position = pane_with_selection.relative_position(&position); pane_with_selection.update_selection(&relative_position, client_id); } } Ok(MouseEffect::default()) }, MouseAction::EndSelection { position } => { Self::execute_end_selection(tab, position, client_id) }, MouseAction::StartMovingFloatingPane { position } => { Self::execute_move_floating_pane(tab, position) }, MouseAction::ContinueMovingFloatingPane { position } => { Self::execute_move_floating_pane(tab, position) }, MouseAction::StopMovingFloatingPane { position } => { Self::execute_stop_moving_floating_pane(tab, position, client_id) }, MouseAction::ScrollUp { pane_id: _, lines } => { Self::handle_scrollwheel_up(tab, &event.position, lines, client_id) .with_context(err_context) }, MouseAction::ScrollDown { pane_id: _, lines } => { Self::handle_scrollwheel_down(tab, &event.position, lines, client_id) .with_context(err_context) }, MouseAction::ScrollLeft { pane_id, cols } => { let scroll_right = false; Self::handle_scrollwheel_horizontal( tab, pane_id, &event.position, cols, scroll_right, client_id, ) .with_context(err_context) }, MouseAction::ScrollRight { pane_id, cols } => { let scroll_right = true; Self::handle_scrollwheel_horizontal( tab, pane_id, &event.position, cols, scroll_right, client_id, ) .with_context(err_context) }, MouseAction::ResizeScrollUp { pane_id } => { Self::handle_resize_scroll_up(tab, pane_id, client_id).with_context(err_context) }, MouseAction::ResizeScrollDown { pane_id } => { Self::handle_resize_scroll_down(tab, pane_id, client_id).with_context(err_context) }, MouseAction::UpdateHover { pane_id, position } => { Self::execute_update_hover(tab, pane_id, position, client_id) }, MouseAction::FocusOnHover { pane_id, position } => { Self::execute_focus_on_hover(tab, pane_id, position, client_id) }, MouseAction::SendToTerminal { pane_id, event } => { Self::execute_send_to_terminal(tab, pane_id, event, client_id) }, MouseAction::FrameIntercepted { pane_id: _ } => { tab.set_force_render(); Ok(MouseEffect::state_changed()) }, MouseAction::NoAction => Ok(MouseEffect::default()), } } fn execute_stop_resize( tab: &mut Tab, position: Position, client_id: ClientId, ) -> Result { let err_context = || "failed to stop resize"; let never_resized = Self::stop_pane_resize_with_mouse(tab, position, client_id) .with_context(err_context)?; if never_resized { let pane_id_at_position = Self::get_pane_at(tab, &position, false) .with_context(err_context)? .map(|p| p.pid()); let active_pane_id = tab .get_active_pane_id(client_id) .ok_or_else(|| anyhow!("Failed to find active pane"))?; if let Some(pane_id) = pane_id_at_position { if pane_id != active_pane_id { Self::focus_pane_at(tab, &position, client_id).with_context(err_context)?; } } } Ok(MouseEffect::state_changed()) } fn execute_focus_pane( tab: &mut Tab, position: Position, client_id: ClientId, ) -> Result { let err_context = || "failed to focus pane"; clear_hover_for_client(tab, client_id); let active_pane_id_before = tab .get_active_pane_id(client_id) .ok_or_else(|| anyhow!("Failed to find active pane"))?; Self::focus_pane_at(tab, &position, client_id).with_context(err_context)?; if let Some(pane_at_position) = Self::unselectable_pane_at_position(tab, &position) { let relative_position = pane_at_position.relative_position(&position); pane_at_position.start_selection(&relative_position, client_id); } if tab.floating_panes.panes_are_visible() { let search_selectable = false; let moved_pane_with_mouse = tab .floating_panes .move_pane_with_mouse(position, search_selectable); let active_pane_id_after = tab .get_active_pane_id(client_id) .ok_or_else(|| anyhow!("Failed to find active pane"))?; if moved_pane_with_mouse || active_pane_id_before != active_pane_id_after { return Ok(MouseEffect::state_changed()); } else { return Ok(MouseEffect::default()); } } let active_pane_id_after = tab .get_active_pane_id(client_id) .ok_or_else(|| anyhow!("Failed to find active pane"))?; if active_pane_id_before != active_pane_id_after { Ok(MouseEffect::state_changed()) } else { Ok(MouseEffect::default()) } } fn execute_focus_pane_and_click_through( tab: &mut Tab, position: Position, click_event: MouseEvent, client_id: ClientId, ) -> Result { let err_context = || "failed to focus pane and click through"; clear_hover_for_client(tab, client_id); Self::focus_pane_at(tab, &position, client_id).with_context(err_context)?; if let Some(pane_at_position) = Self::unselectable_pane_at_position(tab, &position) { let relative_position = pane_at_position.relative_position(&position); pane_at_position.start_selection(&relative_position, client_id); return Ok(MouseEffect::state_changed()); } let active_pane_id = tab .get_active_pane_id(client_id) .ok_or_else(|| anyhow!("Failed to find active pane")) .with_context(err_context)?; let pane = tab .get_pane_with_id(active_pane_id) .ok_or_else(|| anyhow!("Failed to find pane {active_pane_id:?}")) .with_context(err_context)?; let terminal_wants_mouse = pane.terminal_emulator_wants_mouse(); if terminal_wants_mouse { let relative_position = pane.relative_position(&click_event.position); let mut event_for_pane = click_event; event_for_pane.position = relative_position; if let Some(mouse_event) = pane.mouse_event(&event_for_pane, client_id) { if !pane.position_is_on_frame(&click_event.position) { tab.write_to_active_terminal(&None, mouse_event.into_bytes(), false, client_id) .with_context(err_context)?; } } } else { if let Some(pane) = tab.get_pane_with_id_mut(active_pane_id) { let relative_position = pane.relative_position(&position); pane.start_selection(&relative_position, client_id); if pane.supports_mouse_selection() { tab.selecting_with_mouse_in_pane = Some(active_pane_id); } } } Ok(MouseEffect::state_changed()) } fn execute_end_selection( tab: &mut Tab, position: Position, client_id: ClientId, ) -> Result { let err_context = || "failed to end selection"; let mut leave_clipboard_message = false; let copy_on_release = tab.copy_on_select; if let Some(pane_with_selection) = tab .selecting_with_mouse_in_pane .and_then(|p_id| tab.get_pane_with_id_mut(p_id)) { let mut relative_position = pane_with_selection.relative_position(&position); relative_position.change_column( (relative_position.column()) .max(0) .min(pane_with_selection.get_content_columns()), ); relative_position.change_line( (relative_position.line()) .max(0) .min(pane_with_selection.get_content_rows() as isize), ); if let Some(mouse_event) = pane_with_selection.mouse_left_click_release(&relative_position) { tab.write_to_active_terminal(&None, mouse_event.into_bytes(), false, client_id) .with_context(err_context)?; } else { let relative_position = pane_with_selection.relative_position(&position); pane_with_selection.end_selection(&relative_position, client_id); if pane_with_selection.supports_mouse_selection() { if copy_on_release { let selected_text = pane_with_selection.get_selected_text(client_id); if let Some(selected_text) = selected_text { leave_clipboard_message = true; tab.write_selection_to_clipboard(&selected_text) .with_context(err_context)?; } } } tab.selecting_with_mouse_in_pane = None; } } if leave_clipboard_message { Ok(MouseEffect::leave_clipboard_message()) } else { Ok(MouseEffect::default()) } } fn execute_move_floating_pane(tab: &mut Tab, position: Position) -> Result { let search_selectable = false; if tab .floating_panes .move_pane_with_mouse(position, search_selectable) { tab.swap_layouts.set_is_floating_damaged(); tab.set_force_render(); Ok(MouseEffect::state_changed()) } else { Ok(MouseEffect::default()) } } fn execute_stop_moving_floating_pane( tab: &mut Tab, position: Position, client_id: ClientId, ) -> Result { let err_context = || "failed to stop moving floating pane"; let never_moved = tab.floating_panes.stop_moving_pane_with_mouse(position); if never_moved { let active_pane_id = tab .get_active_pane_id(client_id) .ok_or_else(|| anyhow!("Failed to find active pane"))?; let pane_id_at_position = Self::get_pane_at(tab, &position, false) .with_context(err_context)? .ok_or_else(|| anyhow!("Failed to find pane at position"))? .pid(); if active_pane_id != pane_id_at_position { Self::focus_pane_at(tab, &position, client_id).with_context(err_context)?; } } Ok(MouseEffect::default()) } fn execute_focus_on_hover( tab: &mut Tab, pane_id: PaneId, position: Position, client_id: ClientId, ) -> Result { let err_context = || format!("failed to focus pane on hover for client {client_id}"); let is_selectable = tab .get_pane_with_id(pane_id) .map(|p| p.selectable()) .unwrap_or(false); if !is_selectable { return Self::execute_update_hover(tab, Some(pane_id), Some(position), client_id); } let floating_visible = tab.floating_panes.panes_are_visible(); let is_floating = tab.floating_panes.get_pane(pane_id).is_some(); if floating_visible && !is_floating { return Self::execute_update_hover(tab, Some(pane_id), Some(position), client_id); } let is_stacked_one_liner = tab .get_pane_with_id(pane_id) .map(|p| { let geom = p.current_geom(); geom.is_stacked() && geom.rows.is_fixed() }) .unwrap_or(false); if is_stacked_one_liner { return Self::execute_update_hover(tab, Some(pane_id), Some(position), client_id); } if tab.pane_is_hidden_stack_list_member(&pane_id) { return Self::execute_update_hover(tab, Some(pane_id), Some(position), client_id); } let active_pane_id = tab.get_active_pane_id(client_id); if active_pane_id == Some(pane_id) { return Self::execute_update_hover(tab, Some(pane_id), Some(position), client_id); } Self::focus_pane_at(tab, &position, client_id).with_context(err_context)?; clear_hover_for_client(tab, client_id); Ok(MouseEffect::state_changed()) } fn execute_update_hover( tab: &mut Tab, pane_id: Option, position: Option, client_id: ClientId, ) -> Result { let mut should_render = false; let previous_hover_pane_id = tab.mouse_hover_pane_id.get(&client_id).copied(); if tab.mouse_hover_effects { let previous_plugin_hover_pane_id = tab.plugin_hover_pane_id.get(&client_id).copied(); let current_plugin_hover_pane_id = match pane_id { Some(pid) if matches!(pid, PaneId::Plugin(_)) => Some(pid), _ => None, }; if let (Some(pid), Some(position)) = (current_plugin_hover_pane_id, position) { if let Some(pane) = tab.get_pane_with_id(pid) { let relative_position = pane.relative_position(&position); let _ = pane.mouse_event( &MouseEvent::new_buttonless_motion(relative_position), client_id, ); } } if previous_plugin_hover_pane_id != current_plugin_hover_pane_id { if let Some(previous_pid) = previous_plugin_hover_pane_id { if let Some(pane) = tab.get_pane_with_id(previous_pid) { let _ = pane.mouse_event( &MouseEvent::new_buttonless_motion(Position::new(0, u16::MAX)), client_id, ); } } match current_plugin_hover_pane_id { Some(pid) => { tab.plugin_hover_pane_id.insert(client_id, pid); }, None => { tab.plugin_hover_pane_id.remove(&client_id); }, } } } match pane_id { Some(pid) => { if let Some(pane) = tab.get_pane_with_id(pid) { let pane_is_selectable = pane.selectable(); if tab.advanced_mouse_actions && tab.mouse_hover_effects && pane_is_selectable { tab.mouse_hover_pane_id.insert(client_id, pid); } else if tab.advanced_mouse_actions || !tab.mouse_hover_effects { tab.mouse_hover_pane_id.remove(&client_id); } tab.mouse_last_pane_id.insert(client_id, pid); should_render = true; } }, None => { tab.mouse_last_pane_id.remove(&client_id); let removed = tab.mouse_hover_pane_id.remove(&client_id); if removed.is_some() { should_render = true; } }, } if let Some(prev_pane_id) = previous_hover_pane_id { if Some(prev_pane_id) != pane_id { if let Some(pane) = tab.get_pane_with_id_mut(prev_pane_id) { pane.set_hover_position(None); } } } if tab.mouse_help_text_visible.remove(&client_id).is_some() { should_render = true; } let mut mouse_effect = if should_render { MouseEffect::state_changed() } else { MouseEffect::default() }; mouse_effect.leave_clipboard_message = true; Ok(mouse_effect) } fn execute_send_to_terminal( tab: &mut Tab, pane_id: PaneId, event: MouseEvent, client_id: ClientId, ) -> Result { let err_context = || format!("failed to send to terminal for pane {pane_id:?}"); let mut should_render = false; let active_pane_id = tab .get_active_pane_id(client_id) .ok_or_else(|| anyhow!("Failed to find active pane"))?; if pane_id == active_pane_id { let pane = tab .get_pane_with_id(pane_id) .ok_or_else(|| anyhow!("Failed to find pane {pane_id:?}"))?; let relative_position = pane.relative_position(&event.position); let mut event_for_pane = event.clone(); event_for_pane.position = relative_position; if let Some(mouse_event) = pane.mouse_event(&event_for_pane, client_id) { if !pane.position_is_on_frame(&event.position) { tab.write_to_active_terminal(&None, mouse_event.into_bytes(), false, client_id) .with_context(err_context)?; } } if clear_hover_for_client(tab, client_id) { should_render = true; } if event.event_type == MouseEventType::Motion { if let Some(pane) = tab.get_pane_with_id_mut(pane_id) { if !pane.terminal_emulator_wants_mouse() { let relative = pane.relative_position(&event.position); if pane.set_hover_position(Some(relative)) { should_render = true; } } } } if event.event_type == MouseEventType::Motion && tab.mouse_hover_effects { tab.last_mouse_activity_time .insert(client_id, Instant::now()); let entered_pane = tab.mouse_last_pane_id.get(&client_id) != Some(&pane_id); tab.mouse_last_pane_id.insert(client_id, pane_id); if entered_pane && tab.mouse_hover_help_text { let was_visible = tab .mouse_help_text_visible .get(&client_id) .copied() .unwrap_or(false); tab.mouse_help_text_visible.insert(client_id, true); if !was_visible { should_render = true; } tab.senders .send_to_background_jobs(BackgroundJob::ClearHelpText { client_id }) .with_context(err_context)?; } } } let mouse_effect = if should_render { MouseEffect::state_changed() } else { MouseEffect::default() }; Ok(mouse_effect) } fn determine_mouse_action(event: &MouseEvent, ctx: &MouseEventContext) -> Result { if ctx.pane_being_resized { return Ok(match event.event_type { MouseEventType::Motion => MouseAction::ContinueResize { position: event.position, }, MouseEventType::Release => MouseAction::StopResize { position: event.position, }, _ => MouseAction::NoAction, }); } if ctx.selecting_with_mouse { return Ok(match event.event_type { MouseEventType::Motion if event.left => MouseAction::UpdateSelection { position: event.position, }, MouseEventType::Release if event.left => MouseAction::EndSelection { position: event.position, }, _ => MouseAction::NoAction, }); } if ctx.pane_being_moved { return Ok(match event.event_type { MouseEventType::Motion if event.left => MouseAction::ContinueMovingFloatingPane { position: event.position, }, MouseEventType::Release if event.left => MouseAction::StopMovingFloatingPane { position: event.position, }, _ => MouseAction::NoAction, }); } if event.alt { let is_left_press = event.left && event.event_type == MouseEventType::Press; let is_left_motion = event.left && event.event_type == MouseEventType::Motion; if is_left_press { if let Some(pane_id) = ctx.pane_id_at_position { return Ok(MouseAction::GroupToggle(pane_id)); } } if is_left_motion { if let Some(pane_id) = ctx.pane_id_at_position { return Ok(MouseAction::GroupAdd(pane_id)); } } if event.right { return Ok(MouseAction::Ungroup); } return Ok(MouseAction::NoAction); } if event.wheel_up || event.wheel_down { if event.ctrl && !ctx.mouse_scroll_resize { return Ok(MouseAction::NoAction); } if let Some(pane_id) = ctx.pane_id_at_position { if event.ctrl { if event.wheel_up { return Ok(MouseAction::ResizeScrollUp { pane_id }); } if event.wheel_down { return Ok(MouseAction::ResizeScrollDown { pane_id }); } } if event.wheel_up { return Ok(MouseAction::ScrollUp { pane_id, lines: 3 }); } if event.wheel_down { return Ok(MouseAction::ScrollDown { pane_id, lines: 3 }); } } return Ok(MouseAction::NoAction); } if event.wheel_left || event.wheel_right { if let Some(pane_id) = ctx.pane_id_at_position { if event.wheel_left { return Ok(MouseAction::ScrollLeft { pane_id, cols: 4 }); } if event.wheel_right { return Ok(MouseAction::ScrollRight { pane_id, cols: 4 }); } } return Ok(MouseAction::NoAction); } let is_ctrl_left_press = event.ctrl && event.left && event.event_type == MouseEventType::Press; if is_ctrl_left_press { let Some(details) = &ctx.clicked_pane else { return Ok(MouseAction::NoAction); }; if details.on_frame { if details.frame_intercepted { return Ok(MouseAction::FrameIntercepted { pane_id: details.pane_id, }); } if let Some(edge) = details.edge { return Ok(MouseAction::StartResize { pane_id: details.pane_id, edge, is_floating: details.is_floating, position: event.position, }); } } return Ok(MouseAction::NoAction); } // An in-progress title-bar drag owns the mouse until it is released. // Checked before everything else so motion cannot be reinterpreted as // text selection or a resize. if let Some(dragged_pane_id) = ctx.tiled_pane_being_dragged { match event.event_type { MouseEventType::Release => { return Ok(MouseAction::DropDraggedTiledPane { position: event.position, }); }, MouseEventType::Motion => { // Highlight where the drop would land. The pane being // dragged is deliberately NOT highlighted: dropping onto // itself is a no-op, so showing no highlight is the honest // signal that releasing here changes nothing. let _ = dragged_pane_id; return Ok(MouseAction::PreviewDragSwap { target: ctx.pane_id_at_position, }); }, _ => return Ok(MouseAction::NoAction), } } let is_plain_left_press = event.left && event.event_type == MouseEventType::Press && !event.ctrl && !event.alt; if is_plain_left_press { let Some(details) = &ctx.clicked_pane else { return Ok(MouseAction::NoAction); }; let is_active_pane = Some(details.pane_id) == ctx.active_pane_id; let is_pinned_pane = ctx .pinned_selectable .map(|id| id == details.pane_id) .unwrap_or(false); if details.on_frame { if details.frame_intercepted { return Ok(MouseAction::FrameIntercepted { pane_id: details.pane_id, }); } let should_start_moving = ctx.floating_visible || is_pinned_pane; if should_start_moving { return Ok(MouseAction::StartMovingFloatingPane { position: event.position, }); } // A double-click on the pane's title row starts a rename. // Checked before the resize branch, which would otherwise // consume the click as a top-edge resize drag. Corners are // deliberately excluded: they are resize handles. if ctx.frame_double_click && details.on_title { return Ok(MouseAction::StartRenamingPane { pane_id: details.pane_id, }); } // Title bar (minus corners) drags the pane to rearrange it. // Placed AFTER the double-click check so the second click of a // rename is not swallowed, and BEFORE the resize branch, which // would otherwise treat the title row as a corner resize. // Corners fall through to resize. if details.on_title_move_handle { return Ok(MouseAction::StartDraggingTiledPane { pane_id: details.pane_id, }); } if let Some(edge) = details.edge { return Ok(MouseAction::StartResize { pane_id: details.pane_id, edge, is_floating: false, position: event.position, }); } } if is_active_pane { if details.terminal_wants_mouse { return Ok(MouseAction::SendToTerminal { pane_id: details.pane_id, event: *event, }); } else { return Ok(MouseAction::StartSelection { pane_id: details.pane_id, position: event.position, }); } } if !ctx.floating_visible { if let Some(pinned_id) = ctx.pinned_selectable { return Ok(MouseAction::ShowFloatingPanesAndFocus { pane_id: pinned_id }); } if ctx.pinned_unselectable.is_some() { return Ok(MouseAction::NoAction); } } if ctx.mouse_click_through && !ctx.focus_follows_mouse { return Ok(MouseAction::FocusPaneAndClickThrough { pane_id: details.pane_id, position: event.position, event: *event, }); } else { return Ok(MouseAction::FocusPane { pane_id: details.pane_id, position: event.position, }); } } if event.right { let Some(pane_id) = ctx.pane_id_at_position else { return Ok(MouseAction::NoAction); }; let is_active_pane = Some(pane_id) == ctx.active_pane_id; if is_active_pane { return Ok(MouseAction::SendToTerminal { pane_id, event: *event, }); } return Ok(MouseAction::NoAction); } if event.middle { let Some(pane_id) = ctx.pane_id_at_position else { return Ok(MouseAction::NoAction); }; let is_active_pane = Some(pane_id) == ctx.active_pane_id; if is_active_pane { return Ok(MouseAction::SendToTerminal { pane_id, event: *event, }); } return Ok(MouseAction::NoAction); } let is_left_motion_or_release = event.left && (event.event_type == MouseEventType::Motion || event.event_type == MouseEventType::Release); if is_left_motion_or_release { let Some(details) = &ctx.clicked_pane else { return Ok(MouseAction::NoAction); }; let is_active_pane = Some(details.pane_id) == ctx.active_pane_id; if is_active_pane && details.terminal_wants_mouse { return Ok(MouseAction::SendToTerminal { pane_id: details.pane_id, event: *event, }); } return Ok(MouseAction::NoAction); } let is_buttonless_motion = event.event_type == MouseEventType::Motion && !event.left && !event.right && !event.middle; if is_buttonless_motion { let Some(pane_id) = ctx.pane_id_at_position else { return Ok(MouseAction::UpdateHover { pane_id: None, position: None, }); }; let is_active_pane = Some(pane_id) == ctx.active_pane_id; if is_active_pane { return Ok(MouseAction::SendToTerminal { pane_id, event: *event, }); } if ctx.focus_follows_mouse { return Ok(MouseAction::FocusOnHover { pane_id, position: event.position, }); } return Ok(MouseAction::UpdateHover { pane_id: Some(pane_id), position: Some(event.position), }); } Ok(MouseAction::NoAction) } fn unselectable_pane_at_position<'a>( tab: &'a mut Tab, point: &Position, ) -> Option<&'a mut Box> { // the repetition in this function is to appease the borrow checker, I don't like it either let floating_panes_are_visible = tab.floating_panes.panes_are_visible(); if floating_panes_are_visible { if let Ok(Some(clicked_pane_id)) = tab.floating_panes.get_pane_id_at(point, true) { if let Some(pane) = tab.floating_panes.get_pane_mut(clicked_pane_id) { if !pane.selectable() { return Some(pane); } } } else if let Ok(Some(clicked_pane_id)) = tab.get_pane_id_at(point, false) { if let Some(pane) = tab.tiled_panes.get_pane_mut(clicked_pane_id) { if !pane.selectable() { return Some(pane); } } } } else if let Ok(Some(clicked_pane_id)) = tab.get_pane_id_at(point, false) { if let Some(pane) = tab.tiled_panes.get_pane_mut(clicked_pane_id) { if !pane.selectable() { return Some(pane); } } } None } fn focus_pane_at(tab: &mut Tab, point: &Position, client_id: ClientId) -> Result<()> { let err_context = || format!("failed to focus pane at position {point:?} for client {client_id}"); if tab.floating_panes.panes_are_visible() { if let Some(clicked_pane) = tab .floating_panes .get_pane_id_at(point, true) .with_context(err_context)? { tab.floating_panes.focus_pane(clicked_pane, client_id); tab.set_pane_active_at(clicked_pane); return Ok(()); } } if tab.floating_panes.has_pinned_panes() { let search_selectable = false; if let Some(pane_id) = tab .floating_panes .get_pinned_pane_id_at(point, search_selectable) .with_context(err_context)? { tab.floating_panes.focus_pane(pane_id, client_id); tab.set_pane_active_at(pane_id); tab.show_floating_panes(); return Ok(()); } } if let Some(clicked_pane) = tab.get_pane_id_at(point, true).with_context(err_context)? { if !tab.focus_hidden_stack_list_member(clicked_pane, client_id) { tab.tiled_panes.focus_pane(clicked_pane, client_id); } tab.set_pane_active_at(clicked_pane); if tab.floating_panes.panes_are_visible() { tab.hide_floating_panes(); tab.set_force_render(); } } Ok(()) } pub(crate) fn handle_scrollwheel_up( tab: &mut Tab, point: &Position, lines: usize, client_id: ClientId, ) -> Result { let err_context = || { format!("failed to handle scrollwheel up at position {point:?} for client {client_id}") }; if let Some(pane) = Self::get_pane_at(tab, point, false).with_context(err_context)? { let relative_position = pane.relative_position(point); if let Some(mouse_event) = pane.mouse_scroll_up(&relative_position) { tab.write_to_terminal_at(mouse_event.into_bytes(), point, client_id) .with_context(err_context)?; } else if pane.is_alternate_mode_active() { // separate writes so each sequence gets adjusted for cursor keys mode for _ in 0..lines { tab.write_to_terminal_at("\u{1b}[A".as_bytes().to_owned(), point, client_id) .with_context(err_context)?; } } else { pane.scroll_up(lines, client_id); } } Ok(MouseEffect::default()) } pub(crate) fn handle_scrollwheel_down( tab: &mut Tab, point: &Position, lines: usize, client_id: ClientId, ) -> Result { let err_context = || { format!( "failed to handle scrollwheel down at position {point:?} for client {client_id}" ) }; if let Some(pane) = Self::get_pane_at(tab, point, false).with_context(err_context)? { let relative_position = pane.relative_position(point); if let Some(mouse_event) = pane.mouse_scroll_down(&relative_position) { tab.write_to_terminal_at(mouse_event.into_bytes(), point, client_id) .with_context(err_context)?; } else if pane.is_alternate_mode_active() { // separate writes so each sequence gets adjusted for cursor keys mode for _ in 0..lines { tab.write_to_terminal_at("\u{1b}[B".as_bytes().to_owned(), point, client_id) .with_context(err_context)?; } } else { pane.scroll_down(lines, client_id); if !pane.is_scrolled() { if let PaneId::Terminal(pid) = pane.pid() { tab.process_pending_vte_events(pid) .with_context(err_context)?; } } } } Ok(MouseEffect::default()) } pub(crate) fn handle_scrollwheel_horizontal( tab: &mut Tab, pane_id: PaneId, point: &Position, cols: usize, scroll_right: bool, client_id: ClientId, ) -> Result { let err_context = || { format!( "failed to handle horizontal scrollwheel at position {point:?} for client {client_id}" ) }; if !matches!(pane_id, PaneId::Plugin(_)) { return Ok(MouseEffect::default()); } if let Some(pane) = Self::get_pane_at(tab, point, false).with_context(err_context)? { if scroll_right { pane.scroll_right(cols, client_id); } else { pane.scroll_left(cols, client_id); } } Ok(MouseEffect::default()) } fn handle_resize_scroll_up( tab: &mut Tab, pane_id: PaneId, client_id: ClientId, ) -> Result { let err_context = || format!("failed to handle resize scroll up for pane {pane_id:?}"); let is_floating = tab.floating_panes.panes_contain(&pane_id); let strategy = ResizeStrategy { resize: Resize::Increase, direction: None, invert_on_boundaries: false, }; if is_floating { Self::resize_floating_pane_with_strategies(tab, pane_id, &[strategy], (5, 2)) .with_context(err_context)?; tab.swap_layouts.set_is_floating_damaged(); } else { let active_pane_id = tab .get_active_pane_id(client_id) .ok_or_else(|| anyhow!("Failed to find active pane"))?; tab.dissolve_stack_lists_for_classic_mutation(); Self::resize_tiled_pane_with_stacked_resize(tab, active_pane_id, &strategy) .with_context(err_context)?; tab.tiled_panes.reapply_pane_frames(); tab.swap_layouts.set_is_tiled_damaged(); } tab.set_force_render(); Ok(MouseEffect::state_changed()) } fn handle_resize_scroll_down( tab: &mut Tab, pane_id: PaneId, client_id: ClientId, ) -> Result { let err_context = || format!("failed to handle resize scroll down for pane {pane_id:?}"); let is_floating = tab.floating_panes.panes_contain(&pane_id); let strategy = ResizeStrategy { resize: Resize::Decrease, direction: None, invert_on_boundaries: false, }; if is_floating { Self::resize_floating_pane_with_strategies(tab, pane_id, &[strategy], (5, 2)) .with_context(err_context)?; tab.swap_layouts.set_is_floating_damaged(); } else { let active_pane_id = tab .get_active_pane_id(client_id) .ok_or_else(|| anyhow!("Failed to find active pane"))?; tab.dissolve_stack_lists_for_classic_mutation(); Self::resize_tiled_pane_with_stacked_resize(tab, active_pane_id, &strategy) .with_context(err_context)?; tab.tiled_panes.reapply_pane_frames(); tab.swap_layouts.set_is_tiled_damaged(); } tab.set_force_render(); Ok(MouseEffect::state_changed()) } fn get_pane_at<'a>( tab: &'a mut Tab, point: &Position, search_selectable: bool, ) -> Result>> { let err_context = || format!("failed to get pane at position {point:?}"); if tab.floating_panes.panes_are_visible() { if let Some(pane_id) = tab .floating_panes .get_pane_id_at(point, search_selectable) .with_context(err_context)? { return Ok(tab.floating_panes.get_pane_mut(pane_id)); } } else if tab.floating_panes.has_pinned_panes() { if let Some(pane_id) = tab .floating_panes .get_pinned_pane_id_at(point, search_selectable) .with_context(err_context)? { return Ok(tab.floating_panes.get_pane_mut(pane_id)); } } if let Some(pane_id) = tab .get_pane_id_at(point, search_selectable) .with_context(err_context)? { Ok(tab.get_pane_with_id_mut(pane_id)) } else { Ok(None) } } pub(crate) fn set_mouse_selection_support( tab: &mut Tab, pane_id: PaneId, selection_support: bool, ) { if let Some(pane) = tab.get_pane_with_id_mut(pane_id) { pane.set_mouse_selection_support(selection_support); } } } #[cfg(test)] mod tests { use super::*; fn mouse_event_context(mouse_scroll_resize: bool) -> MouseEventContext { MouseEventContext { frame_double_click: false, tiled_pane_being_dragged: None, pane_id_at_position: Some(PaneId::Terminal(1)), active_pane_id: Some(PaneId::Terminal(1)), floating_visible: false, pane_being_resized: false, selecting_with_mouse: false, pane_being_moved: false, clicked_pane: None, pinned_selectable: None, pinned_unselectable: None, focus_follows_mouse: false, mouse_click_through: false, mouse_scroll_resize, } } fn frame_click_context_full( frame_double_click: bool, on_title: bool, on_title_move_handle: bool, edge: Option, ) -> MouseEventContext { let mut ctx = frame_click_context(frame_double_click, on_title, edge); if let Some(details) = ctx.clicked_pane.as_mut() { details.on_title_move_handle = on_title_move_handle; } ctx } fn frame_click_context( frame_double_click: bool, on_title: bool, edge: Option, ) -> MouseEventContext { let mut ctx = mouse_event_context(false); ctx.frame_double_click = frame_double_click; ctx.clicked_pane = Some(ClickedPaneDetails { pane_id: PaneId::Terminal(1), on_frame: true, on_title, on_title_move_handle: false, frame_intercepted: false, edge, is_floating: false, terminal_wants_mouse: false, }); ctx } #[test] fn hover_does_not_move_focus_when_focus_follows_mouse_is_suppressed() { // Tab suppresses focus_follows_mouse while a client is typing into a // rename/search prompt. Without this, hovering after renaming a pane // drags the prompt onto every pane the mouse touches. let mut ctx = mouse_event_context(false); ctx.active_pane_id = Some(PaneId::Terminal(2)); ctx.pane_id_at_position = Some(PaneId::Terminal(1)); ctx.focus_follows_mouse = false; let event = MouseEvent::new_left_motion_event(Position::new(3, 3)); let action = MouseHandler::determine_mouse_action(&event, &ctx).unwrap(); assert!( !matches!(action, MouseAction::FocusOnHover { .. }), "must not steal focus on hover, got {:?}", action ); } #[test] fn a_real_double_click_sequence_is_detected() { // REGRESSION: a double-click is press/release/press/release. An earlier // version reset the tracker on any event that was not a frame press, // which included the RELEASE between the two clicks, so the count never // reached 2 and the feature was dead in production despite the // decision-level tests passing. let mut tracker = Click::default(); let pos = Position::new(1, 5); assert!(!record_frame_click(&mut tracker, &MouseEvent::new_left_press_event(pos), true)); assert!(!record_frame_click(&mut tracker, &MouseEvent::new_left_release_event(pos), true)); assert!( record_frame_click(&mut tracker, &MouseEvent::new_left_press_event(pos), true), "second press at the same position must register as a double-click" ); } #[test] fn motion_between_clicks_does_not_break_the_gesture() { let mut tracker = Click::default(); let pos = Position::new(1, 5); record_frame_click(&mut tracker, &MouseEvent::new_left_press_event(pos), true); record_frame_click(&mut tracker, &MouseEvent::new_left_motion_event(pos), true); record_frame_click(&mut tracker, &MouseEvent::new_left_release_event(pos), true); assert!(record_frame_click( &mut tracker, &MouseEvent::new_left_press_event(pos), true )); } #[test] fn a_press_off_the_frame_breaks_the_sequence() { let mut tracker = Click::default(); let pos = Position::new(1, 5); record_frame_click(&mut tracker, &MouseEvent::new_left_press_event(pos), true); // clicking into pane content is a genuine interruption record_frame_click(&mut tracker, &MouseEvent::new_left_press_event(pos), false); assert!(!record_frame_click( &mut tracker, &MouseEvent::new_left_press_event(pos), true )); } #[test] fn clicks_at_different_positions_are_not_a_double_click() { let mut tracker = Click::default(); record_frame_click(&mut tracker, &MouseEvent::new_left_press_event(Position::new(1, 5)), true); assert!(!record_frame_click( &mut tracker, &MouseEvent::new_left_press_event(Position::new(1, 20)), true )); } #[test] fn dragging_over_the_source_pane_previews_nothing_new() { // the executor treats a target equal to the dragged pane as "the layout // already shows what you would get", so the decision just forwards it let mut ctx = mouse_event_context(false); ctx.tiled_pane_being_dragged = Some(PaneId::Terminal(1)); ctx.pane_id_at_position = Some(PaneId::Terminal(1)); let motion = MouseEvent::new_left_motion_event(Position::new(5, 5)); assert_eq!( MouseHandler::determine_mouse_action(&motion, &ctx).unwrap(), MouseAction::PreviewDragSwap { target: Some(PaneId::Terminal(1)) } ); } #[test] fn dragging_the_title_bar_starts_a_pane_drag() { let ctx = frame_click_context_full(false, true, true, Some(PaneEdge::TopRight)); let event = MouseEvent::new_left_press_event(Position::new(1, 10)); assert_eq!( MouseHandler::determine_mouse_action(&event, &ctx).unwrap(), MouseAction::StartDraggingTiledPane { pane_id: PaneId::Terminal(1) } ); } #[test] fn title_bar_corners_still_resize() { // the corners are deliberately NOT part of the move handle, so two-axis // resize from the top is preserved let ctx = frame_click_context_full(false, true, false, Some(PaneEdge::TopLeft)); let event = MouseEvent::new_left_press_event(Position::new(1, 0)); assert!(matches!( MouseHandler::determine_mouse_action(&event, &ctx).unwrap(), MouseAction::StartResize { .. } )); } #[test] fn double_click_still_renames_even_on_the_move_handle() { // the rename check must come first, or dragging would swallow the // second click of a double-click let ctx = frame_click_context_full(true, true, true, Some(PaneEdge::TopRight)); let event = MouseEvent::new_left_press_event(Position::new(1, 10)); assert_eq!( MouseHandler::determine_mouse_action(&event, &ctx).unwrap(), MouseAction::StartRenamingPane { pane_id: PaneId::Terminal(1) } ); } #[test] fn an_in_progress_drag_owns_the_mouse() { let mut ctx = mouse_event_context(false); ctx.tiled_pane_being_dragged = Some(PaneId::Terminal(1)); ctx.pane_id_at_position = Some(PaneId::Terminal(2)); // motion must not be reinterpreted as selection or resize; it updates // the drop-target highlight instead let motion = MouseEvent::new_left_motion_event(Position::new(5, 5)); assert_eq!( MouseHandler::determine_mouse_action(&motion, &ctx).unwrap(), MouseAction::PreviewDragSwap { target: Some(PaneId::Terminal(2)) } ); // release drops it let release = MouseEvent::new_left_release_event(Position::new(5, 5)); assert_eq!( MouseHandler::determine_mouse_action(&release, &ctx).unwrap(), MouseAction::DropDraggedTiledPane { position: Position::new(5, 5) } ); } #[test] fn double_click_on_pane_title_starts_rename() { // NOTE the edge here is TopRight, not Top: get_edge_at_position() // quadrant-splits a pane and never returns PaneEdge::Top. An earlier // version of this feature keyed off Some(PaneEdge::Top) and was dead // code in production while a hand-built test passed. let ctx = frame_click_context(true, true, Some(PaneEdge::TopRight)); let event = MouseEvent::new_left_press_event(Position::new(1, 1)); assert_eq!( MouseHandler::determine_mouse_action(&event, &ctx).unwrap(), MouseAction::StartRenamingPane { pane_id: PaneId::Terminal(1) } ); } #[test] fn single_click_on_a_title_corner_still_resizes() { // NOTE: not the move handle. A press on the handle now starts a drag; // this covers the corners, which stay resize handles. let ctx = frame_click_context_full(false, true, false, Some(PaneEdge::TopRight)); let event = MouseEvent::new_left_press_event(Position::new(1, 1)); assert!(matches!( MouseHandler::determine_mouse_action(&event, &ctx).unwrap(), MouseAction::StartResize { .. } )); } #[test] fn double_click_elsewhere_on_the_frame_does_not_rename() { // bottom/left/right borders are not the title row for edge in [ PaneEdge::BottomLeft, PaneEdge::BottomRight, PaneEdge::TopLeft, PaneEdge::TopRight, ] { let ctx = frame_click_context(true, false, Some(edge)); let event = MouseEvent::new_left_press_event(Position::new(1, 1)); let action = MouseHandler::determine_mouse_action(&event, &ctx).unwrap(); assert!( !matches!(action, MouseAction::StartRenamingPane { .. }), "edge {:?} off the title row must not rename, got {:?}", edge, action ); } } #[test] fn disabled_ctrl_scroll_does_not_fall_through_to_regular_scrolling() { let context = mouse_event_context(false); let position = Position::new(1, 1); let events = [ MouseEvent::new_ctrl_scroll_up_event(position), MouseEvent::new_ctrl_scroll_down_event(position), ]; for event in events { assert_eq!( MouseHandler::determine_mouse_action(&event, &context).unwrap(), MouseAction::NoAction ); } } }