editor: Support both cursor and mouse based columnar selection (#32779)

Closes #32584

In https://github.com/zed-industries/zed/pull/31888, we changed the
default `opt + shift` behavior to start columnar selection from the
mouse position (Sublime-like behavior) instead of from the existing
selection head (VSCode-like behavior).

It turns out there is a use case for creating columnar selection from an
existing selection head as well, such as creating a consecutive
multi-cursor from existing selection head with just a click instead of
dragging.

This PR brings back columnar selection from the selection head via `opt
+ shift`, while retaining columnar selection from the mouse position,
which is now mapped to the new `cmd + shift` binding.

Note: If you like to swap the binding, you can use [existing multi
cursor modifier
setting](https://zed.dev/docs/configuring-zed?highlight=multi_cursor_modifier#multi-cursor-modifier).

Release Notes:

- Added `cmd + shift` to start columnar selection from the mouse
position.
- Restored `opt + shift` to create columnar selection (or consecutive
multi-cursor on click) from the selection head.
This commit is contained in:
Smit Barmase 2025-06-16 10:13:25 +05:30 committed by GitHub
parent 6150c26bd2
commit ef61ebe049
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 116 additions and 64 deletions

View file

@ -447,6 +447,7 @@ pub enum SelectPhase {
BeginColumnar { BeginColumnar {
position: DisplayPoint, position: DisplayPoint,
reset: bool, reset: bool,
mode: ColumnarMode,
goal_column: u32, goal_column: u32,
}, },
Extend { Extend {
@ -461,6 +462,12 @@ pub enum SelectPhase {
End, End,
} }
#[derive(Clone, Debug, PartialEq)]
pub enum ColumnarMode {
FromMouse,
FromSelection,
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub enum SelectMode { pub enum SelectMode {
Character, Character,
@ -922,6 +929,16 @@ enum SelectionDragState {
}, },
} }
enum ColumnarSelectionState {
FromMouse {
selection_tail: Anchor,
display_point: Option<DisplayPoint>,
},
FromSelection {
selection_tail: Anchor,
},
}
/// Represents a breakpoint indicator that shows up when hovering over lines in the gutter that don't have /// Represents a breakpoint indicator that shows up when hovering over lines in the gutter that don't have
/// a breakpoint on them. /// a breakpoint on them.
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
@ -949,8 +966,7 @@ pub struct Editor {
/// When inline assist editors are linked, they all render cursors because /// When inline assist editors are linked, they all render cursors because
/// typing enters text into each of them, even the ones that aren't focused. /// typing enters text into each of them, even the ones that aren't focused.
pub(crate) show_cursor_when_unfocused: bool, pub(crate) show_cursor_when_unfocused: bool,
columnar_selection_tail: Option<Anchor>, columnar_selection_state: Option<ColumnarSelectionState>,
columnar_display_point: Option<DisplayPoint>,
add_selections_state: Option<AddSelectionsState>, add_selections_state: Option<AddSelectionsState>,
select_next_state: Option<SelectNextState>, select_next_state: Option<SelectNextState>,
select_prev_state: Option<SelectNextState>, select_prev_state: Option<SelectNextState>,
@ -1893,8 +1909,7 @@ impl Editor {
display_map: display_map.clone(), display_map: display_map.clone(),
selections, selections,
scroll_manager: ScrollManager::new(cx), scroll_manager: ScrollManager::new(cx),
columnar_selection_tail: None, columnar_selection_state: None,
columnar_display_point: None,
add_selections_state: None, add_selections_state: None,
select_next_state: None, select_next_state: None,
select_prev_state: None, select_prev_state: None,
@ -3216,7 +3231,8 @@ impl Editor {
position, position,
goal_column, goal_column,
reset, reset,
} => self.begin_columnar_selection(position, goal_column, reset, window, cx), mode,
} => self.begin_columnar_selection(position, goal_column, reset, mode, window, cx),
SelectPhase::Extend { SelectPhase::Extend {
position, position,
click_count, click_count,
@ -3373,6 +3389,7 @@ impl Editor {
position: DisplayPoint, position: DisplayPoint,
goal_column: u32, goal_column: u32,
reset: bool, reset: bool,
mode: ColumnarMode,
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
@ -3395,26 +3412,30 @@ impl Editor {
SelectMode::Character, SelectMode::Character,
); );
}); });
if position.column() != goal_column { };
self.columnar_display_point = Some(DisplayPoint::new(position.row(), goal_column));
} else {
self.columnar_display_point = None;
}
}
let tail = self.selections.newest::<Point>(cx).tail(); let tail = self.selections.newest::<Point>(cx).tail();
self.columnar_selection_tail = Some(display_map.buffer_snapshot.anchor_before(tail)); let selection_anchor = display_map.buffer_snapshot.anchor_before(tail);
self.columnar_selection_state = match mode {
ColumnarMode::FromMouse => Some(ColumnarSelectionState::FromMouse {
selection_tail: selection_anchor,
display_point: if reset {
if position.column() != goal_column {
Some(DisplayPoint::new(position.row(), goal_column))
} else {
None
}
} else {
None
},
}),
ColumnarMode::FromSelection => Some(ColumnarSelectionState::FromSelection {
selection_tail: selection_anchor,
}),
};
if !reset { if !reset {
self.columnar_display_point = None; self.select_columns(position, goal_column, &display_map, window, cx);
self.select_columns(
tail.to_display_point(&display_map),
position,
goal_column,
&display_map,
window,
cx,
);
} }
} }
@ -3428,11 +3449,8 @@ impl Editor {
) { ) {
let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx));
if let Some(tail) = self.columnar_selection_tail.as_ref() { if self.columnar_selection_state.is_some() {
let tail = self self.select_columns(position, goal_column, &display_map, window, cx);
.columnar_display_point
.unwrap_or_else(|| tail.to_display_point(&display_map));
self.select_columns(tail, position, goal_column, &display_map, window, cx);
} else if let Some(mut pending) = self.selections.pending_anchor() { } else if let Some(mut pending) = self.selections.pending_anchor() {
let buffer = self.buffer.read(cx).snapshot(cx); let buffer = self.buffer.read(cx).snapshot(cx);
let head; let head;
@ -3519,7 +3537,7 @@ impl Editor {
} }
fn end_selection(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn end_selection(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.columnar_selection_tail.take(); self.columnar_selection_state.take();
if self.selections.pending_anchor().is_some() { if self.selections.pending_anchor().is_some() {
let selections = self.selections.all::<usize>(cx); let selections = self.selections.all::<usize>(cx);
self.change_selections(None, window, cx, |s| { self.change_selections(None, window, cx, |s| {
@ -3531,13 +3549,26 @@ impl Editor {
fn select_columns( fn select_columns(
&mut self, &mut self,
tail: DisplayPoint,
head: DisplayPoint, head: DisplayPoint,
goal_column: u32, goal_column: u32,
display_map: &DisplaySnapshot, display_map: &DisplaySnapshot,
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
let Some(columnar_state) = self.columnar_selection_state.as_ref() else {
return;
};
let tail = match columnar_state {
ColumnarSelectionState::FromMouse {
selection_tail,
display_point,
} => display_point.unwrap_or_else(|| selection_tail.to_display_point(&display_map)),
ColumnarSelectionState::FromSelection { selection_tail } => {
selection_tail.to_display_point(&display_map)
}
};
let start_row = cmp::min(tail.row(), head.row()); let start_row = cmp::min(tail.row(), head.row());
let end_row = cmp::max(tail.row(), head.row()); let end_row = cmp::max(tail.row(), head.row());
let start_column = cmp::min(tail.column(), goal_column); let start_column = cmp::min(tail.column(), goal_column);
@ -3547,7 +3578,10 @@ impl Editor {
let selection_ranges = (start_row.0..=end_row.0) let selection_ranges = (start_row.0..=end_row.0)
.map(DisplayRow) .map(DisplayRow)
.filter_map(|row| { .filter_map(|row| {
if !display_map.is_block_line(row) { if (matches!(columnar_state, ColumnarSelectionState::FromMouse { .. })
|| start_column <= display_map.line_len(row))
&& !display_map.is_block_line(row)
{
let start = display_map let start = display_map
.clip_point(DisplayPoint::new(row, start_column), Bias::Left) .clip_point(DisplayPoint::new(row, start_column), Bias::Left)
.to_point(display_map); .to_point(display_map);
@ -3565,15 +3599,19 @@ impl Editor {
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let ranges = match columnar_state {
ColumnarSelectionState::FromMouse { .. } => {
let mut non_empty_ranges = selection_ranges let mut non_empty_ranges = selection_ranges
.iter() .iter()
.filter(|selection_range| selection_range.start != selection_range.end) .filter(|selection_range| selection_range.start != selection_range.end)
.peekable(); .peekable();
if non_empty_ranges.peek().is_some() {
let ranges = if non_empty_ranges.peek().is_some() {
non_empty_ranges.cloned().collect() non_empty_ranges.cloned().collect()
} else { } else {
selection_ranges selection_ranges
}
}
_ => selection_ranges,
}; };
self.change_selections(None, window, cx, |s| { self.change_selections(None, window, cx, |s| {
@ -3596,11 +3634,11 @@ impl Editor {
}; };
pending_nonempty_selection pending_nonempty_selection
|| (self.columnar_selection_tail.is_some() && self.selections.disjoint.len() > 1) || (self.columnar_selection_state.is_some() && self.selections.disjoint.len() > 1)
} }
pub fn has_pending_selection(&self) -> bool { pub fn has_pending_selection(&self) -> bool {
self.selections.pending_anchor().is_some() || self.columnar_selection_tail.is_some() self.selections.pending_anchor().is_some() || self.columnar_selection_state.is_some()
} }
pub fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) { pub fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
@ -7188,13 +7226,9 @@ impl Editor {
) )
} }
fn multi_cursor_modifier( fn multi_cursor_modifier(invert: bool, modifiers: &Modifiers, cx: &mut Context<Self>) -> bool {
cursor_event: bool,
modifiers: &Modifiers,
cx: &mut Context<Self>,
) -> bool {
let multi_cursor_setting = EditorSettings::get_global(cx).multi_cursor_modifier; let multi_cursor_setting = EditorSettings::get_global(cx).multi_cursor_modifier;
if cursor_event { if invert {
match multi_cursor_setting { match multi_cursor_setting {
MultiCursorModifier::Alt => modifiers.alt, MultiCursorModifier::Alt => modifiers.alt,
MultiCursorModifier::CmdOrCtrl => modifiers.secondary(), MultiCursorModifier::CmdOrCtrl => modifiers.secondary(),
@ -7207,8 +7241,21 @@ impl Editor {
} }
} }
fn columnar_selection_modifiers(multi_cursor_modifier: bool, modifiers: &Modifiers) -> bool { fn columnar_selection_mode(
modifiers.shift && multi_cursor_modifier && modifiers.number_of_modifiers() == 2 modifiers: &Modifiers,
cx: &mut Context<Self>,
) -> Option<ColumnarMode> {
if modifiers.shift && modifiers.number_of_modifiers() == 2 {
if Self::multi_cursor_modifier(false, modifiers, cx) {
Some(ColumnarMode::FromMouse)
} else if Self::multi_cursor_modifier(true, modifiers, cx) {
Some(ColumnarMode::FromSelection)
} else {
None
}
} else {
None
}
} }
fn update_selection_mode( fn update_selection_mode(
@ -7218,10 +7265,10 @@ impl Editor {
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
let multi_cursor_modifier = Self::multi_cursor_modifier(true, modifiers, cx); let Some(mode) = Self::columnar_selection_mode(modifiers, cx) else {
if !Self::columnar_selection_modifiers(multi_cursor_modifier, modifiers) return;
|| self.selections.pending.is_none() };
{ if self.selections.pending.is_none() {
return; return;
} }
@ -7233,6 +7280,7 @@ impl Editor {
SelectPhase::BeginColumnar { SelectPhase::BeginColumnar {
position, position,
reset: false, reset: false,
mode,
goal_column: point_for_position.exact_unclipped.column(), goal_column: point_for_position.exact_unclipped.column(),
}, },
window, window,

View file

@ -1,13 +1,13 @@
use crate::{ use crate::{
ActiveDiagnostic, BlockId, CURSORS_VISIBLE_FOR, ChunkRendererContext, ChunkReplacement, ActiveDiagnostic, BlockId, CURSORS_VISIBLE_FOR, ChunkRendererContext, ChunkReplacement,
CodeActionSource, ConflictsOurs, ConflictsOursMarker, ConflictsOuter, ConflictsTheirs, CodeActionSource, ColumnarMode, ConflictsOurs, ConflictsOursMarker, ConflictsOuter,
ConflictsTheirsMarker, ContextMenuPlacement, CursorShape, CustomBlockId, DisplayDiffHunk, ConflictsTheirs, ConflictsTheirsMarker, ContextMenuPlacement, CursorShape, CustomBlockId,
DisplayPoint, DisplayRow, DocumentHighlightRead, DocumentHighlightWrite, EditDisplayMode, DisplayDiffHunk, DisplayPoint, DisplayRow, DocumentHighlightRead, DocumentHighlightWrite,
Editor, EditorMode, EditorSettings, EditorSnapshot, EditorStyle, FILE_HEADER_HEIGHT, EditDisplayMode, Editor, EditorMode, EditorSettings, EditorSnapshot, EditorStyle,
FocusedBlock, GutterDimensions, HalfPageDown, HalfPageUp, HandleInput, HoveredCursor, FILE_HEADER_HEIGHT, FocusedBlock, GutterDimensions, HalfPageDown, HalfPageUp, HandleInput,
InlayHintRefreshReason, InlineCompletion, JumpData, LineDown, LineHighlight, LineUp, HoveredCursor, InlayHintRefreshReason, InlineCompletion, JumpData, LineDown, LineHighlight,
MAX_LINE_LEN, MINIMAP_FONT_SIZE, MULTI_BUFFER_EXCERPT_HEADER_HEIGHT, OpenExcerpts, PageDown, LineUp, MAX_LINE_LEN, MINIMAP_FONT_SIZE, MULTI_BUFFER_EXCERPT_HEADER_HEIGHT, OpenExcerpts,
PageUp, PhantomBreakpointIndicator, Point, RowExt, RowRangeExt, SelectPhase, PageDown, PageUp, PhantomBreakpointIndicator, Point, RowExt, RowRangeExt, SelectPhase,
SelectedTextHighlight, Selection, SelectionDragState, SoftWrap, StickyHeaderExcerpt, ToPoint, SelectedTextHighlight, Selection, SelectionDragState, SoftWrap, StickyHeaderExcerpt, ToPoint,
ToggleFold, ToggleFold,
code_context_menus::{CodeActionsMenu, MENU_ASIDE_MAX_WIDTH, MENU_ASIDE_MIN_WIDTH, MENU_GAP}, code_context_menus::{CodeActionsMenu, MENU_ASIDE_MAX_WIDTH, MENU_ASIDE_MIN_WIDTH, MENU_GAP},
@ -700,12 +700,15 @@ impl EditorElement {
} }
let position = point_for_position.previous_valid; let position = point_for_position.previous_valid;
let multi_cursor_modifier = Editor::multi_cursor_modifier(true, &modifiers, cx); if let Some(mode) = Editor::columnar_selection_mode(&modifiers, cx) {
if Editor::columnar_selection_modifiers(multi_cursor_modifier, &modifiers) {
editor.select( editor.select(
SelectPhase::BeginColumnar { SelectPhase::BeginColumnar {
position, position,
reset: true, reset: match mode {
ColumnarMode::FromMouse => true,
ColumnarMode::FromSelection => false,
},
mode: mode,
goal_column: point_for_position.exact_unclipped.column(), goal_column: point_for_position.exact_unclipped.column(),
}, },
window, window,
@ -725,7 +728,7 @@ impl EditorElement {
editor.select( editor.select(
SelectPhase::Begin { SelectPhase::Begin {
position, position,
add: multi_cursor_modifier, add: Editor::multi_cursor_modifier(true, &modifiers, cx),
click_count, click_count,
}, },
window, window,
@ -822,6 +825,7 @@ impl EditorElement {
SelectPhase::BeginColumnar { SelectPhase::BeginColumnar {
position, position,
reset: true, reset: true,
mode: ColumnarMode::FromMouse,
goal_column: point_for_position.exact_unclipped.column(), goal_column: point_for_position.exact_unclipped.column(),
}, },
window, window,