Fix vim selection to include entire range (#2787)

Update vim mode to have vim selection and editor selections match.
Before this we had to adjust between vim selections and real selections
when making changes; now we have to adjust when making selections.

Release Notes:

- vim: Ensure editor selection matches the vim selection
([#1796](https://github.com/zed-industries/community/issues/1796)).
- vim: Fix `s` in visual line mode
- vim: Add `o` and `shift-o` to toggle direction of visual selection
- vim: Fix `v` and `shift-v` to toggle back to normal mode
- vim: Fix block selections like `vi}` to contain correct whitespace
This commit is contained in:
Conrad Irwin 2023-08-15 08:36:17 -06:00 committed by GitHub
commit 404b1aa65a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 1007 additions and 454 deletions

View file

@ -353,19 +353,26 @@ impl DisplaySnapshot {
}
}
// used by line_mode selections and tries to match vim behaviour
pub fn expand_to_line(&self, range: Range<Point>) -> Range<Point> {
let mut new_start = self.prev_line_boundary(range.start).0;
let mut new_end = self.next_line_boundary(range.end).0;
let new_start = if range.start.row == 0 {
Point::new(0, 0)
} else if range.start.row == self.max_buffer_row()
|| (range.end.column > 0 && range.end.row == self.max_buffer_row())
{
Point::new(range.start.row - 1, self.line_len(range.start.row - 1))
} else {
self.prev_line_boundary(range.start).0
};
if new_start.row == range.start.row && new_end.row == range.end.row {
if new_end.row < self.buffer_snapshot.max_point().row {
new_end.row += 1;
new_end.column = 0;
} else if new_start.row > 0 {
new_start.row -= 1;
new_start.column = self.buffer_snapshot.line_len(new_start.row);
}
}
let new_end = if range.end.column == 0 {
range.end
} else if range.end.row < self.max_buffer_row() {
self.buffer_snapshot
.clip_point(Point::new(range.end.row + 1, 0), Bias::Left)
} else {
self.buffer_snapshot.max_point()
};
new_start..new_end
}

View file

@ -63,6 +63,7 @@ struct SelectionLayout {
cursor_shape: CursorShape,
is_newest: bool,
range: Range<DisplayPoint>,
active_rows: Range<u32>,
}
impl SelectionLayout {
@ -73,25 +74,44 @@ impl SelectionLayout {
map: &DisplaySnapshot,
is_newest: bool,
) -> Self {
let point_selection = selection.map(|p| p.to_point(&map.buffer_snapshot));
let display_selection = point_selection.map(|p| p.to_display_point(map));
let mut range = display_selection.range();
let mut head = display_selection.head();
let mut active_rows = map.prev_line_boundary(point_selection.start).1.row()
..map.next_line_boundary(point_selection.end).1.row();
// vim visual line mode
if line_mode {
let selection = selection.map(|p| p.to_point(&map.buffer_snapshot));
let point_range = map.expand_to_line(selection.range());
Self {
head: selection.head().to_display_point(map),
cursor_shape,
is_newest,
range: point_range.start.to_display_point(map)
..point_range.end.to_display_point(map),
}
} else {
let selection = selection.map(|p| p.to_display_point(map));
Self {
head: selection.head(),
cursor_shape,
is_newest,
range: selection.range(),
let point_range = map.expand_to_line(point_selection.range());
range = point_range.start.to_display_point(map)..point_range.end.to_display_point(map);
}
// any vim visual mode (including line mode)
if cursor_shape == CursorShape::Block && !range.is_empty() && !selection.reversed {
if head.column() > 0 {
head = map.clip_point(DisplayPoint::new(head.row(), head.column() - 1), Bias::Left)
} else if head.row() > 0 && head != map.max_point() {
head = map.clip_point(
DisplayPoint::new(head.row() - 1, map.line_len(head.row() - 1)),
Bias::Left,
);
// updating range.end is a no-op unless you're cursor is
// on the newline containing a multi-buffer divider
// in which case the clip_point may have moved the head up
// an additional row.
range.end = DisplayPoint::new(head.row() + 1, 0);
active_rows.end = head.row();
}
}
Self {
head,
cursor_shape,
is_newest,
range,
active_rows,
}
}
}
@ -2152,22 +2172,37 @@ impl Element<Editor> for EditorElement {
}
selections.extend(remote_selections);
let mut newest_selection_head = None;
if editor.show_local_selections {
let mut local_selections = editor
let mut local_selections: Vec<Selection<Point>> = editor
.selections
.disjoint_in_range(start_anchor..end_anchor, cx);
local_selections.extend(editor.selections.pending(cx));
let mut layouts = Vec::new();
let newest = editor.selections.newest(cx);
for selection in &local_selections {
for selection in local_selections.drain(..) {
let is_empty = selection.start == selection.end;
let selection_start = snapshot.prev_line_boundary(selection.start).1;
let selection_end = snapshot.next_line_boundary(selection.end).1;
for row in cmp::max(selection_start.row(), start_row)
..=cmp::min(selection_end.row(), end_row)
let is_newest = selection == newest;
let layout = SelectionLayout::new(
selection,
editor.selections.line_mode,
editor.cursor_shape,
&snapshot.display_snapshot,
is_newest,
);
if is_newest {
newest_selection_head = Some(layout.head);
}
for row in cmp::max(layout.active_rows.start, start_row)
..=cmp::min(layout.active_rows.end, end_row)
{
let contains_non_empty_selection = active_rows.entry(row).or_insert(!is_empty);
*contains_non_empty_selection |= !is_empty;
}
layouts.push(layout);
}
// Render the local selections in the leader's color when following.
@ -2175,22 +2210,7 @@ impl Element<Editor> for EditorElement {
.leader_replica_id
.unwrap_or_else(|| editor.replica_id(cx));
selections.push((
local_replica_id,
local_selections
.into_iter()
.map(|selection| {
let is_newest = selection == newest;
SelectionLayout::new(
selection,
editor.selections.line_mode,
editor.cursor_shape,
&snapshot.display_snapshot,
is_newest,
)
})
.collect(),
));
selections.push((local_replica_id, layouts));
}
let scrollbar_settings = &settings::get::<EditorSettings>(cx).scrollbar;
@ -2295,28 +2315,26 @@ impl Element<Editor> for EditorElement {
snapshot = editor.snapshot(cx);
}
let newest_selection_head = editor
.selections
.newest::<usize>(cx)
.head()
.to_display_point(&snapshot);
let style = editor.style(cx);
let mut context_menu = None;
let mut code_actions_indicator = None;
if (start_row..end_row).contains(&newest_selection_head.row()) {
if editor.context_menu_visible() {
context_menu = editor.render_context_menu(newest_selection_head, style.clone(), cx);
if let Some(newest_selection_head) = newest_selection_head {
if (start_row..end_row).contains(&newest_selection_head.row()) {
if editor.context_menu_visible() {
context_menu =
editor.render_context_menu(newest_selection_head, style.clone(), cx);
}
let active = matches!(
editor.context_menu,
Some(crate::ContextMenu::CodeActions(_))
);
code_actions_indicator = editor
.render_code_actions_indicator(&style, active, cx)
.map(|indicator| (newest_selection_head.row(), indicator));
}
let active = matches!(
editor.context_menu,
Some(crate::ContextMenu::CodeActions(_))
);
code_actions_indicator = editor
.render_code_actions_indicator(&style, active, cx)
.map(|indicator| (newest_selection_head.row(), indicator));
}
let visible_rows = start_row..start_row + line_layouts.len() as u32;
@ -2995,6 +3013,154 @@ mod tests {
assert_eq!(layouts.len(), 6);
}
#[gpui::test]
async fn test_vim_visual_selections(cx: &mut TestAppContext) {
init_test(cx, |_| {});
let editor = cx
.add_window(|cx| {
let buffer = MultiBuffer::build_simple(&(sample_text(6, 6, 'a') + "\n"), cx);
Editor::new(EditorMode::Full, buffer, None, None, cx)
})
.root(cx);
let mut element = EditorElement::new(editor.read_with(cx, |editor, cx| editor.style(cx)));
let (_, state) = editor.update(cx, |editor, cx| {
editor.cursor_shape = CursorShape::Block;
editor.change_selections(None, cx, |s| {
s.select_ranges([
Point::new(0, 0)..Point::new(1, 0),
Point::new(3, 2)..Point::new(3, 3),
Point::new(5, 6)..Point::new(6, 0),
]);
});
let mut new_parents = Default::default();
let mut notify_views_if_parents_change = Default::default();
let mut layout_cx = LayoutContext::new(
cx,
&mut new_parents,
&mut notify_views_if_parents_change,
false,
);
element.layout(
SizeConstraint::new(vec2f(500., 500.), vec2f(500., 500.)),
editor,
&mut layout_cx,
)
});
assert_eq!(state.selections.len(), 1);
let local_selections = &state.selections[0].1;
assert_eq!(local_selections.len(), 3);
// moves cursor back one line
assert_eq!(local_selections[0].head, DisplayPoint::new(0, 6));
assert_eq!(
local_selections[0].range,
DisplayPoint::new(0, 0)..DisplayPoint::new(1, 0)
);
// moves cursor back one column
assert_eq!(
local_selections[1].range,
DisplayPoint::new(3, 2)..DisplayPoint::new(3, 3)
);
assert_eq!(local_selections[1].head, DisplayPoint::new(3, 2));
// leaves cursor on the max point
assert_eq!(
local_selections[2].range,
DisplayPoint::new(5, 6)..DisplayPoint::new(6, 0)
);
assert_eq!(local_selections[2].head, DisplayPoint::new(6, 0));
// active lines does not include 1 (even though the range of the selection does)
assert_eq!(
state.active_rows.keys().cloned().collect::<Vec<u32>>(),
vec![0, 3, 5, 6]
);
// multi-buffer support
// in DisplayPoint co-ordinates, this is what we're dealing with:
// 0: [[file
// 1: header]]
// 2: aaaaaa
// 3: bbbbbb
// 4: cccccc
// 5:
// 6: ...
// 7: ffffff
// 8: gggggg
// 9: hhhhhh
// 10:
// 11: [[file
// 12: header]]
// 13: bbbbbb
// 14: cccccc
// 15: dddddd
let editor = cx
.add_window(|cx| {
let buffer = MultiBuffer::build_multi(
[
(
&(sample_text(8, 6, 'a') + "\n"),
vec![
Point::new(0, 0)..Point::new(3, 0),
Point::new(4, 0)..Point::new(7, 0),
],
),
(
&(sample_text(8, 6, 'a') + "\n"),
vec![Point::new(1, 0)..Point::new(3, 0)],
),
],
cx,
);
Editor::new(EditorMode::Full, buffer, None, None, cx)
})
.root(cx);
let mut element = EditorElement::new(editor.read_with(cx, |editor, cx| editor.style(cx)));
let (_, state) = editor.update(cx, |editor, cx| {
editor.cursor_shape = CursorShape::Block;
editor.change_selections(None, cx, |s| {
s.select_display_ranges([
DisplayPoint::new(4, 0)..DisplayPoint::new(7, 0),
DisplayPoint::new(10, 0)..DisplayPoint::new(13, 0),
]);
});
let mut new_parents = Default::default();
let mut notify_views_if_parents_change = Default::default();
let mut layout_cx = LayoutContext::new(
cx,
&mut new_parents,
&mut notify_views_if_parents_change,
false,
);
element.layout(
SizeConstraint::new(vec2f(500., 500.), vec2f(500., 500.)),
editor,
&mut layout_cx,
)
});
assert_eq!(state.selections.len(), 1);
let local_selections = &state.selections[0].1;
assert_eq!(local_selections.len(), 2);
// moves cursor on excerpt boundary back a line
// and doesn't allow selection to bleed through
assert_eq!(
local_selections[0].range,
DisplayPoint::new(4, 0)..DisplayPoint::new(6, 0)
);
assert_eq!(local_selections[0].head, DisplayPoint::new(5, 0));
// moves cursor on buffer boundary back two lines
// and doesn't allow selection to bleed through
assert_eq!(
local_selections[1].range,
DisplayPoint::new(10, 0)..DisplayPoint::new(11, 0)
);
assert_eq!(local_selections[1].head, DisplayPoint::new(10, 0));
}
#[gpui::test]
fn test_layout_with_placeholder_text_and_blocks(cx: &mut TestAppContext) {
init_test(cx, |_| {});

View file

@ -13,6 +13,13 @@ pub fn left(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint {
map.clip_point(point, Bias::Left)
}
pub fn saturating_left(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint {
if point.column() > 0 {
*point.column_mut() -= 1;
}
map.clip_point(point, Bias::Left)
}
pub fn right(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint {
let max_column = map.line_len(point.row());
if point.column() < max_column {
@ -24,6 +31,11 @@ pub fn right(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint {
map.clip_point(point, Bias::Right)
}
pub fn saturating_right(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint {
*point.column_mut() += 1;
map.clip_point(point, Bias::Right)
}
pub fn up(
map: &DisplaySnapshot,
start: DisplayPoint,

View file

@ -1565,6 +1565,25 @@ impl MultiBuffer {
cx.add_model(|cx| Self::singleton(buffer, cx))
}
pub fn build_multi<const COUNT: usize>(
excerpts: [(&str, Vec<Range<Point>>); COUNT],
cx: &mut gpui::AppContext,
) -> ModelHandle<Self> {
let multi = cx.add_model(|_| Self::new(0));
for (text, ranges) in excerpts {
let buffer = cx.add_model(|cx| Buffer::new(0, text, cx));
let excerpt_ranges = ranges.into_iter().map(|range| ExcerptRange {
context: range,
primary: None,
});
multi.update(cx, |multi, cx| {
multi.push_excerpts(buffer, excerpt_ranges, cx)
});
}
multi
}
pub fn build_from_buffer(
buffer: ModelHandle<Buffer>,
cx: &mut gpui::AppContext,