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 {
position: DisplayPoint,
reset: bool,
mode: ColumnarMode,
goal_column: u32,
},
Extend {
@ -461,6 +462,12 @@ pub enum SelectPhase {
End,
}
#[derive(Clone, Debug, PartialEq)]
pub enum ColumnarMode {
FromMouse,
FromSelection,
}
#[derive(Clone, Debug)]
pub enum SelectMode {
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
/// a breakpoint on them.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@ -949,8 +966,7 @@ pub struct Editor {
/// 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.
pub(crate) show_cursor_when_unfocused: bool,
columnar_selection_tail: Option<Anchor>,
columnar_display_point: Option<DisplayPoint>,
columnar_selection_state: Option<ColumnarSelectionState>,
add_selections_state: Option<AddSelectionsState>,
select_next_state: Option<SelectNextState>,
select_prev_state: Option<SelectNextState>,
@ -1893,8 +1909,7 @@ impl Editor {
display_map: display_map.clone(),
selections,
scroll_manager: ScrollManager::new(cx),
columnar_selection_tail: None,
columnar_display_point: None,
columnar_selection_state: None,
add_selections_state: None,
select_next_state: None,
select_prev_state: None,
@ -3216,7 +3231,8 @@ impl Editor {
position,
goal_column,
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 {
position,
click_count,
@ -3373,6 +3389,7 @@ impl Editor {
position: DisplayPoint,
goal_column: u32,
reset: bool,
mode: ColumnarMode,
window: &mut Window,
cx: &mut Context<Self>,
) {
@ -3395,26 +3412,30 @@ impl Editor {
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();
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 {
self.columnar_display_point = None;
self.select_columns(
tail.to_display_point(&display_map),
position,
goal_column,
&display_map,
window,
cx,
);
self.select_columns(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));
if let Some(tail) = self.columnar_selection_tail.as_ref() {
let tail = self
.columnar_display_point
.unwrap_or_else(|| tail.to_display_point(&display_map));
self.select_columns(tail, position, goal_column, &display_map, window, cx);
if self.columnar_selection_state.is_some() {
self.select_columns(position, goal_column, &display_map, window, cx);
} else if let Some(mut pending) = self.selections.pending_anchor() {
let buffer = self.buffer.read(cx).snapshot(cx);
let head;
@ -3519,7 +3537,7 @@ impl Editor {
}
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() {
let selections = self.selections.all::<usize>(cx);
self.change_selections(None, window, cx, |s| {
@ -3531,13 +3549,26 @@ impl Editor {
fn select_columns(
&mut self,
tail: DisplayPoint,
head: DisplayPoint,
goal_column: u32,
display_map: &DisplaySnapshot,
window: &mut Window,
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 end_row = cmp::max(tail.row(), head.row());
let start_column = cmp::min(tail.column(), goal_column);
@ -3547,7 +3578,10 @@ impl Editor {
let selection_ranges = (start_row.0..=end_row.0)
.map(DisplayRow)
.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
.clip_point(DisplayPoint::new(row, start_column), Bias::Left)
.to_point(display_map);
@ -3565,15 +3599,19 @@ impl Editor {
})
.collect::<Vec<_>>();
let mut non_empty_ranges = selection_ranges
.iter()
.filter(|selection_range| selection_range.start != selection_range.end)
.peekable();
let ranges = if non_empty_ranges.peek().is_some() {
non_empty_ranges.cloned().collect()
} else {
selection_ranges
let ranges = match columnar_state {
ColumnarSelectionState::FromMouse { .. } => {
let mut non_empty_ranges = selection_ranges
.iter()
.filter(|selection_range| selection_range.start != selection_range.end)
.peekable();
if non_empty_ranges.peek().is_some() {
non_empty_ranges.cloned().collect()
} else {
selection_ranges
}
}
_ => selection_ranges,
};
self.change_selections(None, window, cx, |s| {
@ -3596,11 +3634,11 @@ impl Editor {
};
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 {
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>) {
@ -7188,13 +7226,9 @@ impl Editor {
)
}
fn multi_cursor_modifier(
cursor_event: bool,
modifiers: &Modifiers,
cx: &mut Context<Self>,
) -> bool {
fn multi_cursor_modifier(invert: bool, modifiers: &Modifiers, cx: &mut Context<Self>) -> bool {
let multi_cursor_setting = EditorSettings::get_global(cx).multi_cursor_modifier;
if cursor_event {
if invert {
match multi_cursor_setting {
MultiCursorModifier::Alt => modifiers.alt,
MultiCursorModifier::CmdOrCtrl => modifiers.secondary(),
@ -7207,8 +7241,21 @@ impl Editor {
}
}
fn columnar_selection_modifiers(multi_cursor_modifier: bool, modifiers: &Modifiers) -> bool {
modifiers.shift && multi_cursor_modifier && modifiers.number_of_modifiers() == 2
fn columnar_selection_mode(
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(
@ -7218,10 +7265,10 @@ impl Editor {
window: &mut Window,
cx: &mut Context<Self>,
) {
let multi_cursor_modifier = Self::multi_cursor_modifier(true, modifiers, cx);
if !Self::columnar_selection_modifiers(multi_cursor_modifier, modifiers)
|| self.selections.pending.is_none()
{
let Some(mode) = Self::columnar_selection_mode(modifiers, cx) else {
return;
};
if self.selections.pending.is_none() {
return;
}
@ -7233,6 +7280,7 @@ impl Editor {
SelectPhase::BeginColumnar {
position,
reset: false,
mode,
goal_column: point_for_position.exact_unclipped.column(),
},
window,

View file

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