diff --git a/app/src/terminal/alt_screen/alt_screen_element.rs b/app/src/terminal/alt_screen/alt_screen_element.rs index a7bc75208e..3d759c95de 100644 --- a/app/src/terminal/alt_screen/alt_screen_element.rs +++ b/app/src/terminal/alt_screen/alt_screen_element.rs @@ -273,13 +273,35 @@ impl AltScreenElement { SelectionType::from_click_count(click_count) }; - if should_intercept_mouse(&self.model.lock(), mouse_state.modifiers().shift, app) { - ctx.dispatch_typed_action(TerminalAction::AltSelect(SelectAction::Begin { - point, - side, - selection_type, - position: local_position, - })); + let (should_intercept, has_selection) = { + let model = self.model.lock(); + ( + should_intercept_mouse(&model, mouse_state.modifiers().shift, app), + model.alt_screen().selection().is_some(), + ) + }; + + if should_intercept { + let should_extend_selection = mouse_state.modifiers().shift + && selection_type == SelectionType::Simple + && self.highlighted_url.is_none() + && self.hovered_secret.is_none() + && has_selection; + + if should_extend_selection { + ctx.dispatch_typed_action(TerminalAction::AltSelect(SelectAction::Extend { + point, + side, + position: local_position, + })); + } else { + ctx.dispatch_typed_action(TerminalAction::AltSelect(SelectAction::Begin { + point, + side, + selection_type, + position: local_position, + })); + } } else { ctx.dispatch_typed_action(TerminalAction::MaybeClearAltSelect); ctx.dispatch_typed_action(TerminalAction::AltMouseAction(mouse_state.set_point(point))); diff --git a/app/src/terminal/block_list_element.rs b/app/src/terminal/block_list_element.rs index 12e8ea759b..f2f3cc748c 100644 --- a/app/src/terminal/block_list_element.rs +++ b/app/src/terminal/block_list_element.rs @@ -606,6 +606,7 @@ pub struct BlockListElement { scroll_position: ScrollPosition, is_terminal_focused: bool, is_terminal_selecting: bool, + is_extending_block_text_selection: bool, /// This map contains the IDs of sessions that were subshells as keys. Their corresponding /// values are the command that spawned the subshell, which is needed to paint the "flag" subshell_sessions: HashMap, @@ -911,6 +912,8 @@ impl BlockListElement { scroll_position: terminal_view_render_context.scroll_position, is_terminal_focused: terminal_view_render_context.is_terminal_focused, is_terminal_selecting: terminal_view_render_context.is_terminal_selecting, + is_extending_block_text_selection: terminal_view_render_context + .is_extending_block_text_selection, subshell_sessions: terminal_view_render_context.spawning_command_for_subshell_sessions, subshell_flags: HashMap::new(), size: None, @@ -1597,41 +1600,65 @@ impl BlockListElement { } } - // If the find bar is open, allow selecting the active block again so users can - // scope find to that block. - let find_bar_open = self.find_model.as_ref(app).is_find_bar_open(); - // If there's only a non-simple selection, clear the clicked block to avoid a - // text selection and a block selection being rendered at the same time. - // Note we should only dispatch block selection actions when the mouse is not - // clicking on highlighted links or a rich block or if this mouse_down is unselecting - // text. - if selection_type == SelectionType::Simple + // iTerm-style shift+left-click extends the existing text selection + // by moving the nearest boundary instead of starting a fresh one. + // Only kicks in when there's actually a selection to extend and the + // click isn't on a link or a revealed secret. + let extend_text_selection = modifiers.shift + && selection_type == SelectionType::Simple && self.highlighted_url.is_none() && self.hovered_secret.is_none() - && model.block_list().selection().is_none() - // Clicking on an active long running block should focus that block instead, - // except when the find bar is open, in which case we allow selecting it. - && (!on_long_running_block || find_bar_open) - { - ctx.dispatch_typed_action(TerminalAction::BlockSelect { - action: BlockSelectAction::MouseDown(Some(block_index)), - should_redetermine_focus, - }); - } else { + && model.block_list().selection().is_some(); + + if extend_text_selection { ctx.dispatch_typed_action(TerminalAction::BlockSelect { action: BlockSelectAction::ClearAllBlocks, should_redetermine_focus, }); - } + ctx.dispatch_typed_action(TerminalAction::BlockTextSelect( + BlockTextSelectAction::Extend { + point, + side, + position, + }, + )); + } else { + // If the find bar is open, allow selecting the active block again so users can + // scope find to that block. + let find_bar_open = self.find_model.as_ref(app).is_find_bar_open(); + // If there's only a non-simple selection, clear the clicked block to avoid a + // text selection and a block selection being rendered at the same time. + // Note we should only dispatch block selection actions when the mouse is not + // clicking on highlighted links or a rich block or if this mouse_down is unselecting + // text. + if selection_type == SelectionType::Simple + && self.highlighted_url.is_none() + && self.hovered_secret.is_none() + && model.block_list().selection().is_none() + // Clicking on an active long running block should focus that block instead, + // except when the find bar is open, in which case we allow selecting it. + && (!on_long_running_block || find_bar_open) + { + ctx.dispatch_typed_action(TerminalAction::BlockSelect { + action: BlockSelectAction::MouseDown(Some(block_index)), + should_redetermine_focus, + }); + } else { + ctx.dispatch_typed_action(TerminalAction::BlockSelect { + action: BlockSelectAction::ClearAllBlocks, + should_redetermine_focus, + }); + } - ctx.dispatch_typed_action(TerminalAction::BlockTextSelect( - BlockTextSelectAction::Begin { - point, - side, - selection_type, - position, - }, - )); + ctx.dispatch_typed_action(TerminalAction::BlockTextSelect( + BlockTextSelectAction::Begin { + point, + side, + selection_type, + position, + }, + )); + } } // While rich content blocks can't be selected like command blocks, // text selections can still originate in them (i.e. with AI blocks) @@ -1972,7 +1999,9 @@ impl BlockListElement { let side = self .size_info .get_mouse_side(position - vec2f(bounds.origin().x(), snackbar_bottom)); - if !is_selecting_blocks { + // Shift+Click Extend starts as a text-selection gesture. Keep allowing drag updates + // even though the Shift modifier would otherwise classify the drag as block selection. + if !is_selecting_blocks || self.is_extending_block_text_selection { if let Some(point) = self.coord_to_point( SnackbarPoint::underneath_snackbar(position), ClampingMode::ClampToGrid, diff --git a/app/src/terminal/model/alt_screen.rs b/app/src/terminal/model/alt_screen.rs index f786d13ae2..52d95e3443 100644 --- a/app/src/terminal/model/alt_screen.rs +++ b/app/src/terminal/model/alt_screen.rs @@ -235,6 +235,21 @@ impl AltScreen { self.set_selection(selection); } + pub fn extend_selection(&mut self, point: Point, side: Side) { + let mut selection = match self.selection.take() { + None => return, + Some(selection) => selection, + }; + + selection.extend_to_nearest_boundary(point, side); + if selection.is_tail_before_head() { + selection.set_smart_select_side(Direction::Right); + } else { + selection.set_smart_select_side(Direction::Left); + } + self.set_selection(selection); + } + fn set_selection(&mut self, value: Selection) { self.selection = Some(value); self.event_proxy diff --git a/app/src/terminal/model/alt_screen_tests.rs b/app/src/terminal/model/alt_screen_tests.rs index f7375d9379..b0ea4d1e64 100644 --- a/app/src/terminal/model/alt_screen_tests.rs +++ b/app/src/terminal/model/alt_screen_tests.rs @@ -86,6 +86,73 @@ fn test_alt_screen_single_cell_selection() { ); } +#[test] +fn test_alt_screen_extend_selection_to_nearest_boundary() { + let mut screen = new_alt_screen(default_size()); + let semantic_selection = SemanticSelection::mock(false, ""); + + screen.start_selection(Point::new(2, 1), SelectionType::Simple, Side::Left); + screen.update_selection(Point::new(7, 1), Side::Right); + + screen.extend_selection(Point::new(9, 1), Side::Right); + assert_eq!( + screen.selection_range(&semantic_selection), + Some(ExpandedSelectionRange::Regular { + start: Point::new(2, 1), + end: Point::new(9, 1), + reversed: false, + }) + ); + + screen.extend_selection(Point::new(1, 1), Side::Left); + assert_eq!( + screen.selection_range(&semantic_selection), + Some(ExpandedSelectionRange::Regular { + start: Point::new(1, 1), + end: Point::new(9, 1), + reversed: false, + }) + ); +} + +#[test] +fn test_alt_screen_extend_selection_shrinks_inside_selection() { + let mut screen = new_alt_screen(default_size()); + let semantic_selection = SemanticSelection::mock(false, ""); + + screen.start_selection(Point::new(2, 1), SelectionType::Simple, Side::Left); + screen.update_selection(Point::new(8, 1), Side::Right); + + screen.extend_selection(Point::new(3, 1), Side::Left); + assert_eq!( + screen.selection_range(&semantic_selection), + Some(ExpandedSelectionRange::Regular { + start: Point::new(3, 1), + end: Point::new(8, 1), + reversed: false, + }) + ); + + screen.extend_selection(Point::new(7, 1), Side::Right); + assert_eq!( + screen.selection_range(&semantic_selection), + Some(ExpandedSelectionRange::Regular { + start: Point::new(3, 1), + end: Point::new(7, 1), + reversed: false, + }) + ); +} + +#[test] +fn test_alt_screen_extend_selection_without_existing_selection_noops() { + let mut screen = new_alt_screen(default_size()); + + screen.extend_selection(Point::new(3, 1), Side::Right); + + assert!(screen.selection().is_none()); +} + #[test] fn test_accumulate_lines_to_scroll() { let mut screen = new_alt_screen(default_size()); diff --git a/app/src/terminal/model/blocks/selection.rs b/app/src/terminal/model/blocks/selection.rs index e3964b2452..ff18afc993 100644 --- a/app/src/terminal/model/blocks/selection.rs +++ b/app/src/terminal/model/blocks/selection.rs @@ -20,7 +20,10 @@ use crate::env_vars::env_var_collection_block::EnvVarCollectionBlock; use crate::terminal::event::Event as TerminalEvent; use crate::terminal::model::block::BlockSection; use crate::terminal::model::index::{Direction, Point, Side}; -use crate::terminal::model::selection::{ExpandedSelectionRange, Selection, SelectionDirection}; +use crate::terminal::model::selection::{ + should_extend_selection_start, ExpandedSelectionRange, Selection, SelectionDirection, + SelectionPoint, +}; use crate::terminal::model::terminal_model::{BlockIndex, WithinBlock}; use crate::terminal::warpify::success_block::WarpifySuccessBlock; use crate::terminal::GridType; @@ -107,6 +110,35 @@ impl BlockListSelection { } } + fn selection_point(point: BlockListPoint) -> SelectionPoint { + SelectionPoint { + row: point.row, + col: point.column, + } + } + + /// Extend the selection by moving whichever boundary is closest to `point`. + pub fn extend_to_nearest_boundary(&mut self, point: BlockListPoint, side: Side) { + let target = BlockAnchor::new(point, side); + let head_is_selection_start = !Self::points_need_swap(self.head.point, self.tail.point); + let (selection_start, selection_end) = if head_is_selection_start { + (self.head, self.tail) + } else { + (self.tail, self.head) + }; + + let should_update_selection_start = should_extend_selection_start( + Self::selection_point(point), + Self::selection_point(selection_start.point), + Self::selection_point(selection_end.point), + ); + + match (should_update_selection_start, head_is_selection_start) { + (true, true) | (false, false) => self.head = target, + (true, false) | (false, true) => self.tail = target, + } + } + /// Given a block list position (offset from the top-left corner of the /// block list), returns the specific cell (row/column index within a /// particular grid within a particular block) closest to the given point. @@ -443,6 +475,18 @@ impl BlockList { self.set_selection(selection); } + /// Used to extend an existing selection to a new BlockListPoint, moving whichever boundary + /// is closest to the point. Must have an existing selection to update. + pub fn extend_selection_to_nearest_boundary(&mut self, point: BlockListPoint, side: Side) { + let Some(mut selection) = self.selection.take() else { + return; + }; + + selection.extend_to_nearest_boundary(point, side); + + self.set_selection(selection); + } + /// Coordinates an update to the tail of the block-text selection. Returns the BlockListPoint /// of the new tail, if an update was made. pub fn move_selection_tail( diff --git a/app/src/terminal/model/blocks/selection_tests.rs b/app/src/terminal/model/blocks/selection_tests.rs index d13930a770..6ac77f2c74 100644 --- a/app/src/terminal/model/blocks/selection_tests.rs +++ b/app/src/terminal/model/blocks/selection_tests.rs @@ -102,6 +102,124 @@ pub fn test_selection_range_cleared_when_block_finishes() { ); } +#[test] +pub fn test_extend_selection_to_nearest_boundary() { + let mut block_list = + new_bootstrapped_block_list(None, None, ChannelEventListener::new_for_test()); + + block_list.start_selection( + BlockListPoint::new(10.0, 2), + SelectionType::Simple, + Side::Left, + ); + block_list.update_selection(BlockListPoint::new(20.0, 2), Side::Right); + + block_list.extend_selection_to_nearest_boundary(BlockListPoint::new(25.0, 4), Side::Right); + let selection = block_list.selection().expect("selection should exist"); + assert_lines_approx_eq!(selection.head.point.row, 10.0); + assert_eq!(selection.head.point.column, 2); + assert_lines_approx_eq!(selection.tail.point.row, 25.0); + assert_eq!(selection.tail.point.column, 4); + + block_list.extend_selection_to_nearest_boundary(BlockListPoint::new(5.0, 1), Side::Left); + let selection = block_list.selection().expect("selection should exist"); + assert_lines_approx_eq!(selection.head.point.row, 5.0); + assert_eq!(selection.head.point.column, 1); + assert_lines_approx_eq!(selection.tail.point.row, 25.0); + assert_eq!(selection.tail.point.column, 4); +} + +#[test] +pub fn test_extend_selection_to_nearest_boundary_shrinks_inside_selection() { + let mut block_list = + new_bootstrapped_block_list(None, None, ChannelEventListener::new_for_test()); + + block_list.start_selection( + BlockListPoint::new(10.0, 2), + SelectionType::Simple, + Side::Left, + ); + block_list.update_selection(BlockListPoint::new(20.0, 2), Side::Right); + + block_list.extend_selection_to_nearest_boundary(BlockListPoint::new(12.0, 4), Side::Left); + let selection = block_list.selection().expect("selection should exist"); + assert_lines_approx_eq!(selection.head.point.row, 12.0); + assert_eq!(selection.head.point.column, 4); + assert_lines_approx_eq!(selection.tail.point.row, 20.0); + assert_eq!(selection.tail.point.column, 2); + + block_list.extend_selection_to_nearest_boundary(BlockListPoint::new(18.0, 4), Side::Right); + let selection = block_list.selection().expect("selection should exist"); + assert_lines_approx_eq!(selection.head.point.row, 12.0); + assert_eq!(selection.head.point.column, 4); + assert_lines_approx_eq!(selection.tail.point.row, 18.0); + assert_eq!(selection.tail.point.column, 4); +} + +#[test] +pub fn test_extend_selection_to_nearest_boundary_shrinks_same_row_by_column() { + let mut block_list = + new_bootstrapped_block_list(None, None, ChannelEventListener::new_for_test()); + + block_list.start_selection( + BlockListPoint::new(10.0, 2), + SelectionType::Simple, + Side::Left, + ); + block_list.update_selection(BlockListPoint::new(10.0, 20), Side::Right); + + block_list.extend_selection_to_nearest_boundary(BlockListPoint::new(10.0, 4), Side::Left); + let selection = block_list.selection().expect("selection should exist"); + assert_lines_approx_eq!(selection.head.point.row, 10.0); + assert_eq!(selection.head.point.column, 4); + assert_lines_approx_eq!(selection.tail.point.row, 10.0); + assert_eq!(selection.tail.point.column, 20); + + block_list.extend_selection_to_nearest_boundary(BlockListPoint::new(10.0, 18), Side::Right); + let selection = block_list.selection().expect("selection should exist"); + assert_lines_approx_eq!(selection.head.point.row, 10.0); + assert_eq!(selection.head.point.column, 4); + assert_lines_approx_eq!(selection.tail.point.row, 10.0); + assert_eq!(selection.tail.point.column, 18); +} + +#[test] +pub fn test_extend_selection_to_nearest_boundary_handles_reversed_selection() { + let mut block_list = + new_bootstrapped_block_list(None, None, ChannelEventListener::new_for_test()); + + block_list.start_selection( + BlockListPoint::new(20.0, 2), + SelectionType::Simple, + Side::Right, + ); + block_list.update_selection(BlockListPoint::new(10.0, 2), Side::Left); + + block_list.extend_selection_to_nearest_boundary(BlockListPoint::new(5.0, 4), Side::Left); + let selection = block_list.selection().expect("selection should exist"); + assert_lines_approx_eq!(selection.head.point.row, 20.0); + assert_eq!(selection.head.point.column, 2); + assert_lines_approx_eq!(selection.tail.point.row, 5.0); + assert_eq!(selection.tail.point.column, 4); + + block_list.extend_selection_to_nearest_boundary(BlockListPoint::new(25.0, 4), Side::Right); + let selection = block_list.selection().expect("selection should exist"); + assert_lines_approx_eq!(selection.head.point.row, 25.0); + assert_eq!(selection.head.point.column, 4); + assert_lines_approx_eq!(selection.tail.point.row, 5.0); + assert_eq!(selection.tail.point.column, 4); +} + +#[test] +pub fn test_extend_selection_to_nearest_boundary_without_existing_selection_noops() { + let mut block_list = + new_bootstrapped_block_list(None, None, ChannelEventListener::new_for_test()); + + block_list.extend_selection_to_nearest_boundary(BlockListPoint::new(10.0, 4), Side::Right); + + assert!(block_list.selection().is_none()); +} + #[test] pub fn test_selection_ranges_single_command_grid() { let mut blocks = new_bootstrapped_block_list(None, None, ChannelEventListener::new_for_test()); diff --git a/app/src/terminal/model/selection.rs b/app/src/terminal/model/selection.rs index 316f328c33..ca84c5a93b 100644 --- a/app/src/terminal/model/selection.rs +++ b/app/src/terminal/model/selection.rs @@ -14,7 +14,7 @@ use vec1::Vec1; use warp_core::semantic_selection::SemanticSelection; use warp_terminal::model::grid::cell; use warpui::text::SelectionType; -use warpui::units::Lines; +use warpui::units::{IntoLines as _, Lines}; use super::index::{Direction, VisibleRow}; use crate::terminal::model::ansi::CursorShape; @@ -236,6 +236,51 @@ impl Ord for SelectionPoint { } } +impl SelectionPoint { + pub fn from_grid_point(point: Point) -> Self { + Self { + row: point.row.into_lines(), + col: point.col, + } + } +} + +fn point_distance_to_selection_boundary( + point: SelectionPoint, + boundary: SelectionPoint, +) -> (f64, usize) { + ( + (point.row - boundary.row).as_f64().abs(), + point.col.abs_diff(boundary.col), + ) +} + +/// Returns true when extending a selection to `point` should move the start +/// boundary rather than the end boundary. `start` must be before `end`. +pub fn should_extend_selection_start( + point: SelectionPoint, + start: SelectionPoint, + end: SelectionPoint, +) -> bool { + if point <= start { + return true; + } + + if point >= end { + return false; + } + + let (start_row_distance, start_col_distance) = + point_distance_to_selection_boundary(point, start); + let (end_row_distance, end_col_distance) = point_distance_to_selection_boundary(point, end); + + if (start_row_distance - end_row_distance).abs() > f64::EPSILON { + return start_row_distance < end_row_distance; + } + + start_col_distance <= end_col_distance +} + #[derive(Debug, Clone)] pub enum SelectAction { Begin { @@ -250,6 +295,14 @@ pub enum SelectAction { delta: Lines, position: Vector2F, }, + /// Extends an existing selection to `point`, moving whichever boundary is + /// nearest to the clicked point. Used for iTerm-style shift+left-click + /// selection extension. + Extend { + point: T, + side: Side, + position: Vector2F, + }, End, } @@ -325,6 +378,32 @@ impl Selection { self.region.end = Anchor::new(point, side); } + /// Extend the selection by moving whichever boundary is closest to `point`. + pub fn extend_to_nearest_boundary(&mut self, point: Point, side: Side) { + let target = Anchor::new(point, side); + let region_start_is_selection_start = + !Self::points_need_swap(self.region.start.point, self.region.end.point); + let (selection_start, selection_end) = if region_start_is_selection_start { + (self.region.start, self.region.end) + } else { + (self.region.end, self.region.start) + }; + + let should_update_selection_start = should_extend_selection_start( + SelectionPoint::from_grid_point(point), + SelectionPoint::from_grid_point(selection_start.point), + SelectionPoint::from_grid_point(selection_end.point), + ); + + match ( + should_update_selection_start, + region_start_is_selection_start, + ) { + (true, true) | (false, false) => self.region.start = target, + (true, false) | (false, true) => self.region.end = target, + } + } + pub fn is_tail_before_head(&self) -> bool { Self::points_need_swap(self.region.start.point, self.region.end.point) } diff --git a/app/src/terminal/model/selection_tests.rs b/app/src/terminal/model/selection_tests.rs index 08781bc4bc..e5c1862703 100644 --- a/app/src/terminal/model/selection_tests.rs +++ b/app/src/terminal/model/selection_tests.rs @@ -127,6 +127,110 @@ fn simple_is_empty() { assert!(!selection.is_empty()); } +#[test] +fn extend_to_nearest_boundary_moves_outer_boundary() { + let mut selection = Selection::new(SelectionType::Simple, Point::new(10, 2), Side::Left); + selection.update(Point::new(20, 2), Side::Right); + + selection.extend_to_nearest_boundary(Point::new(25, 4), Side::Right); + assert_eq!( + selection.region.start, + Anchor::new(Point::new(10, 2), Side::Left) + ); + assert_eq!( + selection.region.end, + Anchor::new(Point::new(25, 4), Side::Right) + ); + + selection.extend_to_nearest_boundary(Point::new(5, 1), Side::Left); + assert_eq!( + selection.region.start, + Anchor::new(Point::new(5, 1), Side::Left) + ); + assert_eq!( + selection.region.end, + Anchor::new(Point::new(25, 4), Side::Right) + ); +} + +#[test] +fn extend_to_nearest_boundary_shrinks_inside_selection() { + let mut selection = Selection::new(SelectionType::Simple, Point::new(10, 2), Side::Left); + selection.update(Point::new(20, 2), Side::Right); + + selection.extend_to_nearest_boundary(Point::new(12, 4), Side::Left); + assert_eq!( + selection.region.start, + Anchor::new(Point::new(12, 4), Side::Left) + ); + assert_eq!( + selection.region.end, + Anchor::new(Point::new(20, 2), Side::Right) + ); + + selection.extend_to_nearest_boundary(Point::new(18, 4), Side::Right); + assert_eq!( + selection.region.start, + Anchor::new(Point::new(12, 4), Side::Left) + ); + assert_eq!( + selection.region.end, + Anchor::new(Point::new(18, 4), Side::Right) + ); +} + +#[test] +fn extend_to_nearest_boundary_shrinks_same_row_by_column() { + let mut selection = Selection::new(SelectionType::Simple, Point::new(10, 2), Side::Left); + selection.update(Point::new(10, 20), Side::Right); + + selection.extend_to_nearest_boundary(Point::new(10, 4), Side::Left); + assert_eq!( + selection.region.start, + Anchor::new(Point::new(10, 4), Side::Left) + ); + assert_eq!( + selection.region.end, + Anchor::new(Point::new(10, 20), Side::Right) + ); + + selection.extend_to_nearest_boundary(Point::new(10, 18), Side::Right); + assert_eq!( + selection.region.start, + Anchor::new(Point::new(10, 4), Side::Left) + ); + assert_eq!( + selection.region.end, + Anchor::new(Point::new(10, 18), Side::Right) + ); +} + +#[test] +fn extend_to_nearest_boundary_handles_reversed_selection() { + let mut selection = Selection::new(SelectionType::Simple, Point::new(20, 2), Side::Right); + selection.update(Point::new(10, 2), Side::Left); + + selection.extend_to_nearest_boundary(Point::new(5, 4), Side::Left); + assert_eq!( + selection.region.start, + Anchor::new(Point::new(20, 2), Side::Right) + ); + assert_eq!( + selection.region.end, + Anchor::new(Point::new(5, 4), Side::Left) + ); + + selection.extend_to_nearest_boundary(Point::new(25, 4), Side::Right); + assert_eq!( + selection.region.start, + Anchor::new(Point::new(25, 4), Side::Right) + ); + assert_eq!( + selection.region.end, + Anchor::new(Point::new(5, 4), Side::Left) + ); +} + #[test] fn simple_max_min_column() { // If the selection starts on the Right side of a cell, it should skip that cell diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index ec95b00699..8beb650c1e 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -2308,6 +2308,7 @@ pub struct TerminalViewRenderContext { pub link_tool_tip: Option, pub is_terminal_focused: bool, pub is_terminal_selecting: bool, + pub is_extending_block_text_selection: bool, pub is_context_menu_open: bool, pub is_waterfall_gap_mode: bool, pub pane_state: SplitPaneState, @@ -2502,6 +2503,8 @@ pub struct TerminalView { /// Whether there is an active text selection. is_selecting: bool, + /// Whether the active block-list text selection gesture began from Shift+Click Extend. + is_extending_block_text_selection: bool, context_menu: ViewHandle>, @@ -4257,6 +4260,7 @@ impl TerminalView { alt_screen_scroll_top: Lines::zero(), horizontal_clipped_scroll_state: Default::default(), is_selecting: false, + is_extending_block_text_selection: false, context_menu_state: None, context_menu, hovered_secret: None, @@ -6588,6 +6592,7 @@ impl TerminalView { // selection visuals don't persist after switching selection focus to the // CLI subagent view. me.is_selecting = false; + me.is_extending_block_text_selection = false; me.block_text_selection_start_position = None; me.clear_selected_text_except(Some(view.id()), ctx); ctx.notify(); @@ -17714,6 +17719,9 @@ impl TerminalView { SelectAction::Update { point, side, delta, .. } => self.update_alt_selection(*point, *side, delta, ctx), + SelectAction::Extend { point, side, .. } => { + self.extend_alt_selection(*point, *side, ctx) + } SelectAction::End => { self.end_alt_selection(ctx); } @@ -17738,6 +17746,7 @@ impl TerminalView { .alt_screen_mut() .start_selection(point, selection_type, side); self.is_selecting = true; + self.is_extending_block_text_selection = false; ctx.notify(); } @@ -17756,9 +17765,26 @@ impl TerminalView { ctx.notify(); } + fn extend_alt_selection(&mut self, point: Point, side: Side, ctx: &mut ViewContext) { + if self.model.lock().alt_screen().selection().is_none() { + // Extend is only dispatched for an existing selection; direct action invocations + // without one are ignored. + return; + } + + self.model + .lock() + .alt_screen_mut() + .extend_selection(point, side); + self.is_selecting = true; + self.is_extending_block_text_selection = false; + ctx.notify(); + } + fn end_alt_selection(&mut self, ctx: &mut ViewContext) { if self.is_selecting { self.is_selecting = false; + self.is_extending_block_text_selection = false; self.maybe_copy_selection_to_clipboard(ctx); ctx.notify(); } else { @@ -17769,6 +17795,7 @@ impl TerminalView { fn end_text_selection(&mut self, ctx: &mut ViewContext) { if self.is_selecting { self.is_selecting = false; + self.is_extending_block_text_selection = false; self.block_text_selection_start_position = None; let selected_text = { @@ -18076,6 +18103,11 @@ impl TerminalView { delta, position, } => self.update_block_text_selection(*point, *side, *delta, *position, ctx), + BlockTextSelectAction::Extend { + point, + side, + position, + } => self.extend_block_text_selection(*point, *side, *position, ctx), BlockTextSelectAction::End => { self.end_text_selection(ctx); } @@ -18481,6 +18513,7 @@ impl TerminalView { .block_list_mut() .start_selection(point, selection_type, side); self.is_selecting = true; + self.is_extending_block_text_selection = false; if self.rich_content_views.is_empty() { ctx.notify(); @@ -18572,6 +18605,43 @@ impl TerminalView { // Clear the selected block index on mouse drag. self.clear_selected_blocks(ctx); + self.model + .lock() + .block_list_mut() + .extend_selection_to_nearest_boundary(point, side); + + ctx.notify(); + } + + /// Extends the existing block-text selection to `point`, keeping the + /// original anchor (head) fixed and moving only the tail. This backs + /// iTerm-style shift+left-click selection extension. If there is no existing + /// selection to extend, falls back to starting a new simple selection at + /// `point` so the gesture always does something sensible. + fn extend_block_text_selection( + &mut self, + point: BlockListPoint, + side: Side, + _position: Vector2F, + ctx: &mut ViewContext, + ) { + let has_selection = self.model.lock().block_list().selection().is_some(); + if !has_selection { + // Extend is only dispatched when a selection already exists. Ignore direct + // invocations that arrive without a selection instead of creating a surprising one. + return; + } + + // This is an explicit selection gesture, not a drag, so there's no + // text-vs-block drag ambiguity to debounce. + self.block_text_selection_start_position = None; + self.is_selecting = true; + self.is_extending_block_text_selection = true; + + // Text and block selections are mutually exclusive context sources; + // extending a text selection clears any block selection. + self.clear_selected_blocks(ctx); + self.model .lock() .block_list_mut() @@ -19870,6 +19940,7 @@ impl TerminalView { // rich content view component (i.e. `CodeEditorView`), setting `is_selecting` to false // will prevent the selection from "spilling" into neighbouring blocks. self.is_selecting = false; + self.is_extending_block_text_selection = false; // TODO(Simon): This doesn't work as intended for nested inline SelectableAreas. // This includes inline action headers, requested commands, and env var collection blocks. @@ -23130,6 +23201,7 @@ impl TerminalView { .expect("terminal should upgrade") .is_focused(app), is_terminal_selecting: self.is_selecting(), + is_extending_block_text_selection: self.is_extending_block_text_selection, is_context_menu_open: self.is_context_menu_open(), is_waterfall_gap_mode: self.is_waterfall_gap_mode(model, app), pane_state,