vim: Fix relative line motion

Conrad Irwin created

Before this change up and down were in display co-ordinates, after this
change they are in fold coordinates (which matches the vim behaviour).

To make this work without causing usabliity problems, a bunch of extra
keyboard shortcuts now work:

- vim: `z {o,c}` to open,close a fold
- vim: `z f` to fold current visual selection
- vim: `g {j,k,up,down}` to move up/down a display line
- vim: `g {0,^,$,home,end}` to get to start/end of a display line

Fixes: zed-industries/community#1562

Change summary

assets/keymaps/vim.json                           |  57 +++
crates/editor/src/display_map.rs                  |  17 +
crates/editor/src/editor.rs                       |  14 
crates/vim/src/motion.rs                          | 265 +++++++++++++---
crates/vim/src/normal.rs                          |  27 +
crates/vim/src/normal/change.rs                   |   6 
crates/vim/src/normal/substitute.rs               |   5 
crates/vim/src/test.rs                            | 142 +++++++++
crates/vim/src/test/neovim_backed_test_context.rs |  28 +
crates/vim/src/test/neovim_connection.rs          |  24 +
crates/vim/src/visual.rs                          |  11 
crates/vim/test_data/test_folds.json              |  23 +
crates/vim/test_data/test_wrapped_lines.json      |  26 +
13 files changed, 579 insertions(+), 66 deletions(-)

Detailed changes

assets/keymaps/vim.json 🔗

@@ -137,10 +137,67 @@
           "partialWord": true
         }
       ],
+      "g j": [
+        "vim::Down",
+        {
+          "displayLines": true
+        }
+      ],
+      "g down": [
+        "vim::Down",
+        {
+          "displayLines": true
+        }
+      ],
+      "g k": [
+        "vim::Up",
+        {
+          "displayLines": true
+        }
+      ],
+      "g up": [
+        "vim::Up",
+        {
+          "displayLines": true
+        }
+      ],
+      "g $": [
+        "vim::EndOfLine",
+        {
+          "displayLines": true
+        }
+      ],
+      "g end": [
+        "vim::EndOfLine",
+        {
+          "displayLines": true
+        }
+      ],
+      "g 0": [
+        "vim::StartOfLine",
+        {
+          "displayLines": true
+        }
+      ],
+      "g home": [
+        "vim::StartOfLine",
+        {
+          "displayLines": true
+        }
+      ],
+      "g ^": [
+        "vim::FirstNonWhitespace",
+        {
+          "displayLines": true
+        }
+      ],
       // z commands
       "z t": "editor::ScrollCursorTop",
       "z z": "editor::ScrollCursorCenter",
       "z b": "editor::ScrollCursorBottom",
+      "z c": "editor::Fold",
+      "z o": "editor::UnfoldLines",
+      "z f": "editor::FoldSelectedRanges",
       // Count support
       "1": [
         "vim::Number",

crates/editor/src/display_map.rs 🔗

@@ -30,6 +30,7 @@ pub use block_map::{
     BlockDisposition, BlockId, BlockProperties, BlockStyle, RenderBlock, TransformBlock,
 };
 
+pub use self::fold_map::FoldPoint;
 pub use self::inlay_map::{Inlay, InlayOffset, InlayPoint};
 
 #[derive(Copy, Clone, Debug, PartialEq, Eq)]
@@ -310,7 +311,7 @@ impl DisplayMap {
 
 pub struct DisplaySnapshot {
     pub buffer_snapshot: MultiBufferSnapshot,
-    fold_snapshot: fold_map::FoldSnapshot,
+    pub fold_snapshot: fold_map::FoldSnapshot,
     inlay_snapshot: inlay_map::InlaySnapshot,
     tab_snapshot: tab_map::TabSnapshot,
     wrap_snapshot: wrap_map::WrapSnapshot,
@@ -438,6 +439,20 @@ impl DisplaySnapshot {
         fold_point.to_inlay_point(&self.fold_snapshot)
     }
 
+    pub fn display_point_to_fold_point(&self, point: DisplayPoint, bias: Bias) -> FoldPoint {
+        let block_point = point.0;
+        let wrap_point = self.block_snapshot.to_wrap_point(block_point);
+        let tab_point = self.wrap_snapshot.to_tab_point(wrap_point);
+        self.tab_snapshot.to_fold_point(tab_point, bias).0
+    }
+
+    pub fn fold_point_to_display_point(&self, fold_point: FoldPoint) -> DisplayPoint {
+        let tab_point = self.tab_snapshot.to_tab_point(fold_point);
+        let wrap_point = self.wrap_snapshot.tab_point_to_wrap_point(tab_point);
+        let block_point = self.block_snapshot.to_block_point(wrap_point);
+        DisplayPoint(block_point)
+    }
+
     pub fn max_point(&self) -> DisplayPoint {
         DisplayPoint(self.block_snapshot.max_point())
     }

crates/editor/src/editor.rs 🔗

@@ -7198,7 +7198,7 @@ impl Editor {
 
         let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx));
 
-        let selections = self.selections.all::<Point>(cx);
+        let selections = self.selections.all_adjusted(cx);
         for selection in selections {
             let range = selection.range().sorted();
             let buffer_start_row = range.start.row;
@@ -7274,7 +7274,17 @@ impl Editor {
 
     pub fn fold_selected_ranges(&mut self, _: &FoldSelectedRanges, cx: &mut ViewContext<Self>) {
         let selections = self.selections.all::<Point>(cx);
-        let ranges = selections.into_iter().map(|s| s.start..s.end);
+        let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx));
+        let line_mode = self.selections.line_mode;
+        let ranges = selections.into_iter().map(|s| {
+            if line_mode {
+                let start = Point::new(s.start.row, 0);
+                let end = Point::new(s.end.row, display_map.buffer_snapshot.line_len(s.end.row));
+                start..end
+            } else {
+                s.start..s.end
+            }
+        });
         self.fold_ranges(ranges, true, cx);
     }
 

crates/vim/src/motion.rs 🔗

@@ -2,7 +2,7 @@ use std::{cmp, sync::Arc};
 
 use editor::{
     char_kind,
-    display_map::{DisplaySnapshot, ToDisplayPoint},
+    display_map::{DisplaySnapshot, FoldPoint, ToDisplayPoint},
     movement, Bias, CharKind, DisplayPoint, ToOffset,
 };
 use gpui::{actions, impl_actions, AppContext, WindowContext};
@@ -21,16 +21,16 @@ use crate::{
 pub enum Motion {
     Left,
     Backspace,
-    Down,
-    Up,
+    Down { display_lines: bool },
+    Up { display_lines: bool },
     Right,
     NextWordStart { ignore_punctuation: bool },
     NextWordEnd { ignore_punctuation: bool },
     PreviousWordStart { ignore_punctuation: bool },
-    FirstNonWhitespace,
+    FirstNonWhitespace { display_lines: bool },
     CurrentLine,
-    StartOfLine,
-    EndOfLine,
+    StartOfLine { display_lines: bool },
+    EndOfLine { display_lines: bool },
     StartOfParagraph,
     EndOfParagraph,
     StartOfDocument,
@@ -62,6 +62,41 @@ struct PreviousWordStart {
     ignore_punctuation: bool,
 }
 
+#[derive(Clone, Deserialize, PartialEq)]
+#[serde(rename_all = "camelCase")]
+struct Up {
+    #[serde(default)]
+    display_lines: bool,
+}
+
+#[derive(Clone, Deserialize, PartialEq)]
+#[serde(rename_all = "camelCase")]
+struct Down {
+    #[serde(default)]
+    display_lines: bool,
+}
+
+#[derive(Clone, Deserialize, PartialEq)]
+#[serde(rename_all = "camelCase")]
+struct FirstNonWhitespace {
+    #[serde(default)]
+    display_lines: bool,
+}
+
+#[derive(Clone, Deserialize, PartialEq)]
+#[serde(rename_all = "camelCase")]
+struct EndOfLine {
+    #[serde(default)]
+    display_lines: bool,
+}
+
+#[derive(Clone, Deserialize, PartialEq)]
+#[serde(rename_all = "camelCase")]
+struct StartOfLine {
+    #[serde(default)]
+    display_lines: bool,
+}
+
 #[derive(Clone, Deserialize, PartialEq)]
 struct RepeatFind {
     #[serde(default)]
@@ -73,12 +108,7 @@ actions!(
     [
         Left,
         Backspace,
-        Down,
-        Up,
         Right,
-        FirstNonWhitespace,
-        StartOfLine,
-        EndOfLine,
         CurrentLine,
         StartOfParagraph,
         EndOfParagraph,
@@ -90,20 +120,63 @@ actions!(
 );
 impl_actions!(
     vim,
-    [NextWordStart, NextWordEnd, PreviousWordStart, RepeatFind]
+    [
+        NextWordStart,
+        NextWordEnd,
+        PreviousWordStart,
+        RepeatFind,
+        Up,
+        Down,
+        FirstNonWhitespace,
+        EndOfLine,
+        StartOfLine,
+    ]
 );
 
 pub fn init(cx: &mut AppContext) {
     cx.add_action(|_: &mut Workspace, _: &Left, cx: _| motion(Motion::Left, cx));
     cx.add_action(|_: &mut Workspace, _: &Backspace, cx: _| motion(Motion::Backspace, cx));
-    cx.add_action(|_: &mut Workspace, _: &Down, cx: _| motion(Motion::Down, cx));
-    cx.add_action(|_: &mut Workspace, _: &Up, cx: _| motion(Motion::Up, cx));
+    cx.add_action(|_: &mut Workspace, action: &Down, cx: _| {
+        motion(
+            Motion::Down {
+                display_lines: action.display_lines,
+            },
+            cx,
+        )
+    });
+    cx.add_action(|_: &mut Workspace, action: &Up, cx: _| {
+        motion(
+            Motion::Up {
+                display_lines: action.display_lines,
+            },
+            cx,
+        )
+    });
     cx.add_action(|_: &mut Workspace, _: &Right, cx: _| motion(Motion::Right, cx));
-    cx.add_action(|_: &mut Workspace, _: &FirstNonWhitespace, cx: _| {
-        motion(Motion::FirstNonWhitespace, cx)
+    cx.add_action(|_: &mut Workspace, action: &FirstNonWhitespace, cx: _| {
+        motion(
+            Motion::FirstNonWhitespace {
+                display_lines: action.display_lines,
+            },
+            cx,
+        )
+    });
+    cx.add_action(|_: &mut Workspace, action: &StartOfLine, cx: _| {
+        motion(
+            Motion::StartOfLine {
+                display_lines: action.display_lines,
+            },
+            cx,
+        )
+    });
+    cx.add_action(|_: &mut Workspace, action: &EndOfLine, cx: _| {
+        motion(
+            Motion::EndOfLine {
+                display_lines: action.display_lines,
+            },
+            cx,
+        )
     });
-    cx.add_action(|_: &mut Workspace, _: &StartOfLine, cx: _| motion(Motion::StartOfLine, cx));
-    cx.add_action(|_: &mut Workspace, _: &EndOfLine, cx: _| motion(Motion::EndOfLine, cx));
     cx.add_action(|_: &mut Workspace, _: &CurrentLine, cx: _| motion(Motion::CurrentLine, cx));
     cx.add_action(|_: &mut Workspace, _: &StartOfParagraph, cx: _| {
         motion(Motion::StartOfParagraph, cx)
@@ -192,19 +265,25 @@ impl Motion {
     pub fn linewise(&self) -> bool {
         use Motion::*;
         match self {
-            Down | Up | StartOfDocument | EndOfDocument | CurrentLine | NextLineStart
-            | StartOfParagraph | EndOfParagraph => true,
-            EndOfLine
+            Down { .. }
+            | Up { .. }
+            | StartOfDocument
+            | EndOfDocument
+            | CurrentLine
+            | NextLineStart
+            | StartOfParagraph
+            | EndOfParagraph => true,
+            EndOfLine { .. }
             | NextWordEnd { .. }
             | Matching
             | FindForward { .. }
             | Left
             | Backspace
             | Right
-            | StartOfLine
+            | StartOfLine { .. }
             | NextWordStart { .. }
             | PreviousWordStart { .. }
-            | FirstNonWhitespace
+            | FirstNonWhitespace { .. }
             | FindBackward { .. } => false,
         }
     }
@@ -213,21 +292,21 @@ impl Motion {
         use Motion::*;
         match self {
             StartOfDocument | EndOfDocument | CurrentLine => true,
-            Down
-            | Up
-            | EndOfLine
+            Down { .. }
+            | Up { .. }
+            | EndOfLine { .. }
             | NextWordEnd { .. }
             | Matching
             | FindForward { .. }
             | Left
             | Backspace
             | Right
-            | StartOfLine
+            | StartOfLine { .. }
             | StartOfParagraph
             | EndOfParagraph
             | NextWordStart { .. }
             | PreviousWordStart { .. }
-            | FirstNonWhitespace
+            | FirstNonWhitespace { .. }
             | FindBackward { .. }
             | NextLineStart => false,
         }
@@ -236,12 +315,12 @@ impl Motion {
     pub fn inclusive(&self) -> bool {
         use Motion::*;
         match self {
-            Down
-            | Up
+            Down { .. }
+            | Up { .. }
             | StartOfDocument
             | EndOfDocument
             | CurrentLine
-            | EndOfLine
+            | EndOfLine { .. }
             | NextWordEnd { .. }
             | Matching
             | FindForward { .. }
@@ -249,12 +328,12 @@ impl Motion {
             Left
             | Backspace
             | Right
-            | StartOfLine
+            | StartOfLine { .. }
             | StartOfParagraph
             | EndOfParagraph
             | NextWordStart { .. }
             | PreviousWordStart { .. }
-            | FirstNonWhitespace
+            | FirstNonWhitespace { .. }
             | FindBackward { .. } => false,
         }
     }
@@ -272,8 +351,18 @@ impl Motion {
         let (new_point, goal) = match self {
             Left => (left(map, point, times), SelectionGoal::None),
             Backspace => (backspace(map, point, times), SelectionGoal::None),
-            Down => down(map, point, goal, times),
-            Up => up(map, point, goal, times),
+            Down {
+                display_lines: false,
+            } => down(map, point, goal, times),
+            Down {
+                display_lines: true,
+            } => down_display(map, point, goal, times),
+            Up {
+                display_lines: false,
+            } => up(map, point, goal, times),
+            Up {
+                display_lines: true,
+            } => up_display(map, point, goal, times),
             Right => (right(map, point, times), SelectionGoal::None),
             NextWordStart { ignore_punctuation } => (
                 next_word_start(map, point, *ignore_punctuation, times),
@@ -287,9 +376,17 @@ impl Motion {
                 previous_word_start(map, point, *ignore_punctuation, times),
                 SelectionGoal::None,
             ),
-            FirstNonWhitespace => (first_non_whitespace(map, point), SelectionGoal::None),
-            StartOfLine => (start_of_line(map, point), SelectionGoal::None),
-            EndOfLine => (end_of_line(map, point), SelectionGoal::None),
+            FirstNonWhitespace { display_lines } => (
+                first_non_whitespace(map, *display_lines, point),
+                SelectionGoal::None,
+            ),
+            StartOfLine { display_lines } => (
+                start_of_line(map, *display_lines, point),
+                SelectionGoal::None,
+            ),
+            EndOfLine { display_lines } => {
+                (end_of_line(map, *display_lines, point), SelectionGoal::None)
+            }
             StartOfParagraph => (
                 movement::start_of_paragraph(map, point, times),
                 SelectionGoal::None,
@@ -298,7 +395,7 @@ impl Motion {
                 map.clip_at_line_end(movement::end_of_paragraph(map, point, times)),
                 SelectionGoal::None,
             ),
-            CurrentLine => (end_of_line(map, point), SelectionGoal::None),
+            CurrentLine => (end_of_line(map, false, point), SelectionGoal::None),
             StartOfDocument => (start_of_document(map, point, times), SelectionGoal::None),
             EndOfDocument => (
                 end_of_document(map, point, maybe_times),
@@ -400,14 +497,38 @@ fn backspace(map: &DisplaySnapshot, mut point: DisplayPoint, times: usize) -> Di
 
 fn down(
     map: &DisplaySnapshot,
-    mut point: DisplayPoint,
+    point: DisplayPoint,
     mut goal: SelectionGoal,
     times: usize,
 ) -> (DisplayPoint, SelectionGoal) {
-    let start_row = point.to_point(map).row;
-    let target = cmp::min(map.max_buffer_row(), start_row + times as u32);
+    let start = map.display_point_to_fold_point(point, Bias::Left);
+
+    let goal_column = match goal {
+        SelectionGoal::Column(column) => column,
+        SelectionGoal::ColumnRange { end, .. } => end,
+        _ => {
+            goal = SelectionGoal::Column(start.column());
+            start.column()
+        }
+    };
 
-    while point.to_point(map).row < target {
+    let new_row = cmp::min(
+        start.row() + times as u32,
+        map.buffer_snapshot.max_point().row,
+    );
+    let new_col = cmp::min(goal_column, map.fold_snapshot.line_len(new_row));
+    let point = map.fold_point_to_display_point(FoldPoint::new(new_row, new_col));
+
+    (map.clip_point(point, Bias::Left), goal)
+}
+
+fn down_display(
+    map: &DisplaySnapshot,
+    mut point: DisplayPoint,
+    mut goal: SelectionGoal,
+    times: usize,
+) -> (DisplayPoint, SelectionGoal) {
+    for _ in 0..times {
         (point, goal) = movement::down(map, point, goal, true);
     }
 
@@ -416,16 +537,38 @@ fn down(
 
 fn up(
     map: &DisplaySnapshot,
-    mut point: DisplayPoint,
+    point: DisplayPoint,
     mut goal: SelectionGoal,
     times: usize,
 ) -> (DisplayPoint, SelectionGoal) {
-    let start_row = point.to_point(map).row;
-    let target = start_row.saturating_sub(times as u32);
+    let start = map.display_point_to_fold_point(point, Bias::Left);
+
+    let goal_column = match goal {
+        SelectionGoal::Column(column) => column,
+        SelectionGoal::ColumnRange { end, .. } => end,
+        _ => {
+            goal = SelectionGoal::Column(start.column());
+            start.column()
+        }
+    };
+
+    let new_row = start.row().saturating_sub(times as u32);
+    let new_col = cmp::min(goal_column, map.fold_snapshot.line_len(new_row));
+    let point = map.fold_point_to_display_point(FoldPoint::new(new_row, new_col));
 
-    while point.to_point(map).row > target {
+    (map.clip_point(point, Bias::Left), goal)
+}
+
+fn up_display(
+    map: &DisplaySnapshot,
+    mut point: DisplayPoint,
+    mut goal: SelectionGoal,
+    times: usize,
+) -> (DisplayPoint, SelectionGoal) {
+    for _ in 0..times {
         (point, goal) = movement::up(map, point, goal, true);
     }
+
     (point, goal)
 }
 
@@ -516,8 +659,12 @@ fn previous_word_start(
     point
 }
 
-fn first_non_whitespace(map: &DisplaySnapshot, from: DisplayPoint) -> DisplayPoint {
-    let mut last_point = DisplayPoint::new(from.row(), 0);
+fn first_non_whitespace(
+    map: &DisplaySnapshot,
+    display_lines: bool,
+    from: DisplayPoint,
+) -> DisplayPoint {
+    let mut last_point = start_of_line(map, display_lines, from);
     let language = map.buffer_snapshot.language_at(from.to_point(map));
     for (ch, point) in map.chars_at(last_point) {
         if ch == '\n' {
@@ -534,12 +681,23 @@ fn first_non_whitespace(map: &DisplaySnapshot, from: DisplayPoint) -> DisplayPoi
     map.clip_point(last_point, Bias::Left)
 }
 
-fn start_of_line(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint {
-    map.prev_line_boundary(point.to_point(map)).1
+fn start_of_line(map: &DisplaySnapshot, display_lines: bool, point: DisplayPoint) -> DisplayPoint {
+    if display_lines {
+        map.clip_point(DisplayPoint::new(point.row(), 0), Bias::Right)
+    } else {
+        map.prev_line_boundary(point.to_point(map)).1
+    }
 }
 
-fn end_of_line(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint {
-    map.clip_point(map.next_line_boundary(point.to_point(map)).1, Bias::Left)
+fn end_of_line(map: &DisplaySnapshot, display_lines: bool, point: DisplayPoint) -> DisplayPoint {
+    if display_lines {
+        map.clip_point(
+            DisplayPoint::new(point.row(), map.line_len(point.row())),
+            Bias::Left,
+        )
+    } else {
+        map.clip_point(map.next_line_boundary(point.to_point(map)).1, Bias::Left)
+    }
 }
 
 fn start_of_document(map: &DisplaySnapshot, point: DisplayPoint, line: usize) -> DisplayPoint {
@@ -664,6 +822,7 @@ fn next_line_start(map: &DisplaySnapshot, point: DisplayPoint, times: usize) ->
     let new_row = (point.row() + times as u32).min(map.max_buffer_row());
     first_non_whitespace(
         map,
+        false,
         map.clip_point(DisplayPoint::new(new_row, 0), Bias::Left),
     )
 }

crates/vim/src/normal.rs 🔗

@@ -78,13 +78,27 @@ pub fn init(cx: &mut AppContext) {
     cx.add_action(|_: &mut Workspace, _: &ChangeToEndOfLine, cx| {
         Vim::update(cx, |vim, cx| {
             let times = vim.pop_number_operator(cx);
-            change_motion(vim, Motion::EndOfLine, times, cx);
+            change_motion(
+                vim,
+                Motion::EndOfLine {
+                    display_lines: false,
+                },
+                times,
+                cx,
+            );
         })
     });
     cx.add_action(|_: &mut Workspace, _: &DeleteToEndOfLine, cx| {
         Vim::update(cx, |vim, cx| {
             let times = vim.pop_number_operator(cx);
-            delete_motion(vim, Motion::EndOfLine, times, cx);
+            delete_motion(
+                vim,
+                Motion::EndOfLine {
+                    display_lines: false,
+                },
+                times,
+                cx,
+            );
         })
     });
     scroll::init(cx);
@@ -165,7 +179,10 @@ fn insert_first_non_whitespace(
         vim.update_active_editor(cx, |editor, cx| {
             editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
                 s.maybe_move_cursors_with(|map, cursor, goal| {
-                    Motion::FirstNonWhitespace.move_point(map, cursor, goal, None)
+                    Motion::FirstNonWhitespace {
+                        display_lines: false,
+                    }
+                    .move_point(map, cursor, goal, None)
                 });
             });
         });
@@ -178,7 +195,7 @@ fn insert_end_of_line(_: &mut Workspace, _: &InsertEndOfLine, cx: &mut ViewConte
         vim.update_active_editor(cx, |editor, cx| {
             editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
                 s.maybe_move_cursors_with(|map, cursor, goal| {
-                    Motion::EndOfLine.move_point(map, cursor, goal, None)
+                    Motion::CurrentLine.move_point(map, cursor, goal, None)
                 });
             });
         });
@@ -238,7 +255,7 @@ fn insert_line_below(_: &mut Workspace, _: &InsertLineBelow, cx: &mut ViewContex
                 });
                 editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
                     s.maybe_move_cursors_with(|map, cursor, goal| {
-                        Motion::EndOfLine.move_point(map, cursor, goal, None)
+                        Motion::CurrentLine.move_point(map, cursor, goal, None)
                     });
                 });
                 editor.edit_with_autoindent(edits, cx);

crates/vim/src/normal/change.rs 🔗

@@ -10,7 +10,11 @@ pub fn change_motion(vim: &mut Vim, motion: Motion, times: Option<usize>, cx: &m
     // Some motions ignore failure when switching to normal mode
     let mut motion_succeeded = matches!(
         motion,
-        Motion::Left | Motion::Right | Motion::EndOfLine | Motion::Backspace | Motion::StartOfLine
+        Motion::Left
+            | Motion::Right
+            | Motion::EndOfLine { .. }
+            | Motion::Backspace
+            | Motion::StartOfLine { .. }
     );
     vim.update_active_editor(cx, |editor, cx| {
         editor.transact(cx, |editor, cx| {

crates/vim/src/normal/substitute.rs 🔗

@@ -15,7 +15,10 @@ pub fn substitute(vim: &mut Vim, count: Option<usize>, cx: &mut WindowContext) {
                     }
                     if line_mode {
                         Motion::CurrentLine.expand_selection(map, selection, None, false);
-                        if let Some((point, _)) = Motion::FirstNonWhitespace.move_point(
+                        if let Some((point, _)) = (Motion::FirstNonWhitespace {
+                            display_lines: false,
+                        })
+                        .move_point(
                             map,
                             selection.start,
                             selection.goal,

crates/vim/src/test.rs 🔗

@@ -285,3 +285,145 @@ async fn test_word_characters(cx: &mut gpui::TestAppContext) {
         Mode::Visual,
     )
 }
+
+#[gpui::test]
+async fn test_wrapped_lines(cx: &mut gpui::TestAppContext) {
+    let mut cx = NeovimBackedTestContext::new(cx).await;
+
+    cx.set_shared_wrap(12).await;
+    // tests line wrap as follows:
+    //  1: twelve char
+    //     twelve char
+    //  2: twelve char
+    cx.set_shared_state(indoc! { "
+        tˇwelve char twelve char
+        twelve char
+    "})
+        .await;
+    cx.simulate_shared_keystrokes(["j"]).await;
+    cx.assert_shared_state(indoc! { "
+        twelve char twelve char
+        tˇwelve char
+    "})
+        .await;
+    cx.simulate_shared_keystrokes(["k"]).await;
+    cx.assert_shared_state(indoc! { "
+        tˇwelve char twelve char
+        twelve char
+    "})
+        .await;
+    cx.simulate_shared_keystrokes(["g", "j"]).await;
+    cx.assert_shared_state(indoc! { "
+        twelve char tˇwelve char
+        twelve char
+    "})
+        .await;
+    cx.simulate_shared_keystrokes(["g", "j"]).await;
+    cx.assert_shared_state(indoc! { "
+        twelve char twelve char
+        tˇwelve char
+    "})
+        .await;
+
+    cx.simulate_shared_keystrokes(["g", "k"]).await;
+    cx.assert_shared_state(indoc! { "
+        twelve char tˇwelve char
+        twelve char
+    "})
+        .await;
+
+    cx.simulate_shared_keystrokes(["g", "^"]).await;
+    cx.assert_shared_state(indoc! { "
+        twelve char ˇtwelve char
+        twelve char
+    "})
+        .await;
+
+    cx.simulate_shared_keystrokes(["^"]).await;
+    cx.assert_shared_state(indoc! { "
+        ˇtwelve char twelve char
+        twelve char
+    "})
+        .await;
+
+    cx.simulate_shared_keystrokes(["g", "$"]).await;
+    cx.assert_shared_state(indoc! { "
+        twelve charˇ twelve char
+        twelve char
+    "})
+        .await;
+    cx.simulate_shared_keystrokes(["$"]).await;
+    cx.assert_shared_state(indoc! { "
+        twelve char twelve chaˇr
+        twelve char
+    "})
+        .await;
+}
+
+#[gpui::test]
+async fn test_folds(cx: &mut gpui::TestAppContext) {
+    let mut cx = NeovimBackedTestContext::new(cx).await;
+    cx.set_neovim_option("foldmethod=manual").await;
+
+    cx.set_shared_state(indoc! { "
+        fn boop() {
+          ˇbarp()
+          bazp()
+        }
+    "})
+        .await;
+    cx.simulate_shared_keystrokes(["shift-v", "j", "z", "f"])
+        .await;
+
+    // visual display is now:
+    // fn boop () {
+    //  [FOLDED]
+    // }
+
+    // TODO: this should not be needed but currently zf does not
+    // return to normal mode.
+    cx.simulate_shared_keystrokes(["escape"]).await;
+
+    // skip over fold downward
+    cx.simulate_shared_keystrokes(["g", "g"]).await;
+    cx.assert_shared_state(indoc! { "
+        ˇfn boop() {
+          barp()
+          bazp()
+        }
+    "})
+        .await;
+
+    cx.simulate_shared_keystrokes(["j", "j"]).await;
+    cx.assert_shared_state(indoc! { "
+        fn boop() {
+          barp()
+          bazp()
+        ˇ}
+    "})
+        .await;
+
+    // skip over fold upward
+    cx.simulate_shared_keystrokes(["2", "k"]).await;
+    cx.assert_shared_state(indoc! { "
+        ˇfn boop() {
+          barp()
+          bazp()
+        }
+    "})
+        .await;
+
+    // yank the fold
+    cx.simulate_shared_keystrokes(["down", "y", "y"]).await;
+    cx.assert_shared_clipboard("  barp()\n  bazp()\n").await;
+
+    // re-open
+    cx.simulate_shared_keystrokes(["z", "o"]).await;
+    cx.assert_shared_state(indoc! { "
+        fn boop() {
+        ˇ  barp()
+          bazp()
+        }
+    "})
+        .await;
+}

crates/vim/src/test/neovim_backed_test_context.rs 🔗

@@ -1,9 +1,14 @@
+use editor::EditorSettings;
 use indoc::indoc;
+use settings::SettingsStore;
 use std::ops::{Deref, DerefMut, Range};
 
 use collections::{HashMap, HashSet};
 use gpui::ContextHandle;
-use language::OffsetRangeExt;
+use language::{
+    language_settings::{AllLanguageSettings, LanguageSettings, SoftWrap},
+    OffsetRangeExt,
+};
 use util::test::{generate_marked_text, marked_text_offsets};
 
 use super::{neovim_connection::NeovimConnection, NeovimBackedBindingTestContext, VimTestContext};
@@ -127,6 +132,27 @@ impl<'a> NeovimBackedTestContext<'a> {
         context_handle
     }
 
+    pub async fn set_shared_wrap(&mut self, columns: u32) {
+        if columns < 12 {
+            panic!("nvim doesn't support columns < 12")
+        }
+        self.neovim.set_option("wrap").await;
+        self.neovim.set_option("columns=12").await;
+
+        self.update(|cx| {
+            cx.update_global(|settings: &mut SettingsStore, cx| {
+                settings.update_user_settings::<AllLanguageSettings>(cx, |settings| {
+                    settings.defaults.soft_wrap = Some(SoftWrap::PreferredLineLength);
+                    settings.defaults.preferred_line_length = Some(columns);
+                });
+            })
+        })
+    }
+
+    pub async fn set_neovim_option(&mut self, option: &str) {
+        self.neovim.set_option(option).await;
+    }
+
     pub async fn assert_shared_state(&mut self, marked_text: &str) {
         let neovim = self.neovim_state().await;
         let editor = self.editor_state();

crates/vim/src/test/neovim_connection.rs 🔗

@@ -41,6 +41,7 @@ pub enum NeovimData {
     Key(String),
     Get { state: String, mode: Option<Mode> },
     ReadRegister { name: char, value: String },
+    SetOption { value: String },
 }
 
 pub struct NeovimConnection {
@@ -222,6 +223,29 @@ impl NeovimConnection {
         );
     }
 
+    #[cfg(feature = "neovim")]
+    pub async fn set_option(&mut self, value: &str) {
+        self.nvim
+            .command_output(format!("set {}", value).as_str())
+            .await
+            .unwrap();
+
+        self.data.push_back(NeovimData::SetOption {
+            value: value.to_string(),
+        })
+    }
+
+    #[cfg(not(feature = "neovim"))]
+    pub async fn set_option(&mut self, value: &str) {
+        assert_eq!(
+            self.data.pop_front(),
+            Some(NeovimData::SetOption {
+                value: value.to_string(),
+            }),
+            "operation does not match recorded script. re-record with --features=neovim"
+        );
+    }
+
     #[cfg(not(feature = "neovim"))]
     pub async fn read_register(&mut self, register: char) -> String {
         if let Some(NeovimData::Get { .. }) = self.data.front() {

crates/vim/src/visual.rs 🔗

@@ -51,8 +51,15 @@ pub fn init(cx: &mut AppContext) {
 pub fn visual_motion(motion: Motion, times: Option<usize>, cx: &mut WindowContext) {
     Vim::update(cx, |vim, cx| {
         vim.update_active_editor(cx, |editor, cx| {
-            if vim.state().mode == Mode::VisualBlock && !matches!(motion, Motion::EndOfLine) {
-                let is_up_or_down = matches!(motion, Motion::Up | Motion::Down);
+            if vim.state().mode == Mode::VisualBlock
+                && !matches!(
+                    motion,
+                    Motion::EndOfLine {
+                        display_lines: false
+                    }
+                )
+            {
+                let is_up_or_down = matches!(motion, Motion::Up { .. } | Motion::Down { .. });
                 visual_block_motion(is_up_or_down, editor, cx, |map, point, goal| {
                     motion.move_point(map, point, goal, times)
                 })

crates/vim/test_data/test_folds.json 🔗

@@ -0,0 +1,23 @@
+{"SetOption":{"value":"foldmethod=manual"}}
+{"Put":{"state":"fn boop() {\n  ˇbarp()\n  bazp()\n}\n"}}
+{"Key":"shift-v"}
+{"Key":"j"}
+{"Key":"z"}
+{"Key":"f"}
+{"Key":"escape"}
+{"Key":"g"}
+{"Key":"g"}
+{"Get":{"state":"ˇfn boop() {\n  barp()\n  bazp()\n}\n","mode":"Normal"}}
+{"Key":"j"}
+{"Key":"j"}
+{"Get":{"state":"fn boop() {\n  barp()\n  bazp()\nˇ}\n","mode":"Normal"}}
+{"Key":"2"}
+{"Key":"k"}
+{"Get":{"state":"ˇfn boop() {\n  barp()\n  bazp()\n}\n","mode":"Normal"}}
+{"Key":"down"}
+{"Key":"y"}
+{"Key":"y"}
+{"ReadRegister":{"name":"\"","value":"  barp()\n  bazp()\n"}}
+{"Key":"z"}
+{"Key":"o"}
+{"Get":{"state":"fn boop() {\nˇ  barp()\n  bazp()\n}\n","mode":"Normal"}}

crates/vim/test_data/test_wrapped_lines.json 🔗

@@ -0,0 +1,26 @@
+{"SetOption":{"value":"wrap"}}
+{"SetOption":{"value":"columns=12"}}
+{"Put":{"state":"tˇwelve char twelve char\ntwelve char\n"}}
+{"Key":"j"}
+{"Get":{"state":"twelve char twelve char\ntˇwelve char\n","mode":"Normal"}}
+{"Key":"k"}
+{"Get":{"state":"tˇwelve char twelve char\ntwelve char\n","mode":"Normal"}}
+{"Key":"g"}
+{"Key":"j"}
+{"Get":{"state":"twelve char tˇwelve char\ntwelve char\n","mode":"Normal"}}
+{"Key":"g"}
+{"Key":"j"}
+{"Get":{"state":"twelve char twelve char\ntˇwelve char\n","mode":"Normal"}}
+{"Key":"g"}
+{"Key":"k"}
+{"Get":{"state":"twelve char tˇwelve char\ntwelve char\n","mode":"Normal"}}
+{"Key":"g"}
+{"Key":"^"}
+{"Get":{"state":"twelve char ˇtwelve char\ntwelve char\n","mode":"Normal"}}
+{"Key":"^"}
+{"Get":{"state":"ˇtwelve char twelve char\ntwelve char\n","mode":"Normal"}}
+{"Key":"g"}
+{"Key":"$"}
+{"Get":{"state":"twelve charˇ twelve char\ntwelve char\n","mode":"Normal"}}
+{"Key":"$"}
+{"Get":{"state":"twelve char twelve chaˇr\ntwelve char\n","mode":"Normal"}}