Visual line mode handles soft wraps

Keith Simmons created

Change summary

assets/keymaps/vim.json                    |  23 +++
crates/editor/src/display_map.rs           |  12 ++
crates/editor/src/editor.rs                |  21 ++-
crates/editor/src/element.rs               |  82 ++++++++-----
crates/editor/src/selections_collection.rs |  14 ++
crates/gpui/src/app.rs                     |  50 +++----
crates/vim/src/motion.rs                   |   2 
crates/vim/src/normal.rs                   |   8 
crates/vim/src/normal/yank.rs              |  26 ++++
crates/vim/src/state.rs                    |   2 
crates/vim/src/vim.rs                      |  19 +-
crates/vim/src/vim_test_context.rs         |   8 +
crates/vim/src/visual.rs                   | 139 ++++++++++++++++++++++-
crates/zed/src/main.rs                     |   4 
14 files changed, 314 insertions(+), 96 deletions(-)

Detailed changes

assets/keymaps/vim.json 🔗

@@ -9,7 +9,7 @@
                 }
             ],
             "h": "vim::Left",
-            "backspace": "vim::Left",
+            "backspace": "editor::Backspace", // "vim::Left",
             "j": "vim::Down",
             "k": "vim::Up",
             "l": "vim::Right",
@@ -57,6 +57,10 @@
                 "Delete"
             ],
             "shift-D": "vim::DeleteToEndOfLine",
+            "y": [
+                "vim::PushOperator",
+                "Yank"
+            ],
             "i": [
                 "vim::SwitchMode",
                 "Insert"
@@ -77,7 +81,10 @@
                 "vim::SwitchMode",
                 "VisualLine"
             ],
-            "p": "vim::Paste"
+            "p": "vim::Paste",
+            "u": "editor::Undo",
+            "ctrl-r": "editor::Redo",
+            "ctrl-o": "pane::GoBack"
         }
     },
     {
@@ -109,12 +116,19 @@
             "d": "vim::CurrentLine"
         }
     },
+    {
+        "context": "Editor && vim_operator == y",
+        "bindings": {
+            "y": "vim::CurrentLine"
+        }
+    },
     {
         "context": "Editor && vim_mode == visual",
         "bindings": {
             "c": "vim::VisualChange",
             "d": "vim::VisualDelete",
-            "x": "vim::VisualDelete"
+            "x": "vim::VisualDelete",
+            "y": "vim::VisualYank"
         }
     },
     {
@@ -122,7 +136,8 @@
         "bindings": {
             "c": "vim::VisualLineChange",
             "d": "vim::VisualLineDelete",
-            "x": "vim::VisualLineDelete"
+            "x": "vim::VisualLineDelete",
+            "y": "vim::VisualLineYank"
         }
     },
     {

crates/editor/src/display_map.rs 🔗

@@ -279,6 +279,18 @@ impl DisplaySnapshot {
         }
     }
 
+    pub fn expand_to_line(&self, mut range: Range<Point>) -> Range<Point> {
+        (range.start, _) = self.prev_line_boundary(range.start);
+        (range.end, _) = self.next_line_boundary(range.end);
+
+        if range.is_empty() && range.start.row > 0 {
+            range.start.row -= 1;
+            range.start.column = self.buffer_snapshot.line_len(range.start.row);
+        }
+
+        range
+    }
+
     fn point_to_display_point(&self, point: Point, bias: Bias) -> DisplayPoint {
         let fold_point = self.folds_snapshot.to_fold_point(point, bias);
         let tab_point = self.tabs_snapshot.to_tab_point(fold_point);

crates/editor/src/editor.rs 🔗

@@ -1860,7 +1860,7 @@ impl Editor {
     pub fn insert(&mut self, text: &str, cx: &mut ViewContext<Self>) {
         let text: Arc<str> = text.into();
         self.transact(cx, |this, cx| {
-            let old_selections = this.selections.all::<usize>(cx);
+            let old_selections = this.selections.all_adjusted(cx);
             let selection_anchors = this.buffer.update(cx, |buffer, cx| {
                 let anchors = {
                     let snapshot = buffer.read(cx);
@@ -2750,7 +2750,7 @@ impl Editor {
         let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx));
         let mut selections = self.selections.all::<Point>(cx);
         for selection in &mut selections {
-            if selection.is_empty() {
+            if selection.is_empty() && !self.selections.line_mode {
                 let old_head = selection.head();
                 let mut new_head =
                     movement::left(&display_map, old_head.to_display_point(&display_map))
@@ -2783,8 +2783,9 @@ impl Editor {
     pub fn delete(&mut self, _: &Delete, cx: &mut ViewContext<Self>) {
         self.transact(cx, |this, cx| {
             this.change_selections(Some(Autoscroll::Fit), cx, |s| {
+                let line_mode = s.line_mode;
                 s.move_with(|map, selection| {
-                    if selection.is_empty() {
+                    if selection.is_empty() && !line_mode {
                         let cursor = movement::right(map, selection.head());
                         selection.set_head(cursor, SelectionGoal::None);
                     }
@@ -2807,7 +2808,7 @@ impl Editor {
             return;
         }
 
-        let mut selections = self.selections.all::<Point>(cx);
+        let mut selections = self.selections.all_adjusted(cx);
         if selections.iter().all(|s| s.is_empty()) {
             self.transact(cx, |this, cx| {
                 this.buffer.update(cx, |buffer, cx| {
@@ -3347,7 +3348,7 @@ impl Editor {
         {
             let max_point = buffer.max_point();
             for selection in &mut selections {
-                let is_entire_line = selection.is_empty();
+                let is_entire_line = selection.is_empty() || self.selections.line_mode;
                 if is_entire_line {
                     selection.start = Point::new(selection.start.row, 0);
                     selection.end = cmp::min(max_point, Point::new(selection.end.row + 1, 0));
@@ -3378,16 +3379,17 @@ impl Editor {
         let selections = self.selections.all::<Point>(cx);
         let buffer = self.buffer.read(cx).read(cx);
         let mut text = String::new();
+
         let mut clipboard_selections = Vec::with_capacity(selections.len());
         {
             let max_point = buffer.max_point();
             for selection in selections.iter() {
                 let mut start = selection.start;
                 let mut end = selection.end;
-                let is_entire_line = selection.is_empty();
+                let is_entire_line = selection.is_empty() || self.selections.line_mode;
                 if is_entire_line {
                     start = Point::new(start.row, 0);
-                    end = cmp::min(max_point, Point::new(start.row + 1, 0));
+                    end = cmp::min(max_point, Point::new(end.row + 1, 0));
                 }
                 let mut len = 0;
                 for chunk in buffer.text_for_range(start..end) {
@@ -3453,7 +3455,7 @@ impl Editor {
                                 let line_start = selection.start - column;
                                 line_start..line_start
                             } else {
-                                selection.start..selection.end
+                                selection.range()
                             };
 
                             edits.push((range, to_insert));
@@ -3670,8 +3672,9 @@ impl Editor {
     ) {
         self.transact(cx, |this, cx| {
             this.change_selections(Some(Autoscroll::Fit), cx, |s| {
+                let line_mode = s.line_mode;
                 s.move_with(|map, selection| {
-                    if selection.is_empty() {
+                    if selection.is_empty() && !line_mode {
                         let cursor = movement::previous_word_start(map, selection.head());
                         selection.set_head(cursor, SelectionGoal::None);
                     }

crates/editor/src/element.rs 🔗

@@ -3,7 +3,10 @@ use super::{
     Anchor, DisplayPoint, Editor, EditorMode, EditorSnapshot, Input, Scroll, Select, SelectPhase,
     SoftWrap, ToPoint, MAX_LINE_LEN,
 };
-use crate::{display_map::TransformBlock, EditorStyle};
+use crate::{
+    display_map::{DisplaySnapshot, TransformBlock},
+    EditorStyle,
+};
 use clock::ReplicaId;
 use collections::{BTreeMap, HashMap};
 use gpui::{
@@ -22,7 +25,7 @@ use gpui::{
     MutableAppContext, PaintContext, Quad, Scene, SizeConstraint, ViewContext, WeakViewHandle,
 };
 use json::json;
-use language::{Bias, DiagnosticSeverity};
+use language::{Bias, DiagnosticSeverity, Selection};
 use settings::Settings;
 use smallvec::SmallVec;
 use std::{
@@ -32,6 +35,35 @@ use std::{
     ops::Range,
 };
 
+struct SelectionLayout {
+    head: DisplayPoint,
+    range: Range<DisplayPoint>,
+}
+
+impl SelectionLayout {
+    fn from<T: ToPoint + ToDisplayPoint + Clone>(
+        selection: Selection<T>,
+        line_mode: bool,
+        map: &DisplaySnapshot,
+    ) -> Self {
+        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),
+                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(),
+                range: selection.range(),
+            }
+        }
+    }
+}
+
 pub struct EditorElement {
     view: WeakViewHandle<Editor>,
     style: EditorStyle,
@@ -345,19 +377,18 @@ impl EditorElement {
                 scroll_top,
                 scroll_left,
                 bounds,
-                false,
                 cx,
             );
         }
 
         let mut cursors = SmallVec::<[Cursor; 32]>::new();
-        for ((replica_id, line_mode), selections) in &layout.selections {
+        for (replica_id, selections) in &layout.selections {
             let selection_style = style.replica_selection_style(*replica_id);
             let corner_radius = 0.15 * layout.line_height;
 
             for selection in selections {
                 self.paint_highlighted_range(
-                    selection.start..selection.end,
+                    selection.range.clone(),
                     start_row,
                     end_row,
                     selection_style.selection,
@@ -368,12 +399,11 @@ impl EditorElement {
                     scroll_top,
                     scroll_left,
                     bounds,
-                    *line_mode,
                     cx,
                 );
 
                 if view.show_local_cursors() || *replica_id != local_replica_id {
-                    let cursor_position = selection.head();
+                    let cursor_position = selection.head;
                     if (start_row..end_row).contains(&cursor_position.row()) {
                         let cursor_row_layout =
                             &layout.line_layouts[(cursor_position.row() - start_row) as usize];
@@ -485,11 +515,10 @@ impl EditorElement {
         scroll_top: f32,
         scroll_left: f32,
         bounds: RectF,
-        line_mode: bool,
         cx: &mut PaintContext,
     ) {
-        if range.start != range.end || line_mode {
-            let row_range = if range.end.column() == 0 && !line_mode {
+        if range.start != range.end {
+            let row_range = if range.end.column() == 0 {
                 cmp::max(range.start.row(), start_row)..cmp::min(range.end.row(), end_row)
             } else {
                 cmp::max(range.start.row(), start_row)..cmp::min(range.end.row() + 1, end_row)
@@ -506,14 +535,14 @@ impl EditorElement {
                     .map(|row| {
                         let line_layout = &layout.line_layouts[(row - start_row) as usize];
                         HighlightedRangeLine {
-                            start_x: if row == range.start.row() && !line_mode {
+                            start_x: if row == range.start.row() {
                                 content_origin.x()
                                     + line_layout.x_for_index(range.start.column() as usize)
                                     - scroll_left
                             } else {
                                 content_origin.x() - scroll_left
                             },
-                            end_x: if row == range.end.row() && !line_mode {
+                            end_x: if row == range.end.row() {
                                 content_origin.x()
                                     + line_layout.x_for_index(range.end.column() as usize)
                                     - scroll_left
@@ -921,7 +950,7 @@ impl Element for EditorElement {
                 .anchor_before(DisplayPoint::new(end_row, 0).to_offset(&snapshot, Bias::Right))
         };
 
-        let mut selections = Vec::new();
+        let mut selections: Vec<(ReplicaId, Vec<SelectionLayout>)> = Vec::new();
         let mut active_rows = BTreeMap::new();
         let mut highlighted_rows = None;
         let mut highlighted_ranges = Vec::new();
@@ -945,17 +974,10 @@ impl Element for EditorElement {
                 if Some(replica_id) == view.leader_replica_id {
                     continue;
                 }
-
                 remote_selections
-                    .entry((replica_id, line_mode))
+                    .entry(replica_id)
                     .or_insert(Vec::new())
-                    .push(crate::Selection {
-                        id: selection.id,
-                        goal: selection.goal,
-                        reversed: selection.reversed,
-                        start: selection.start.to_display_point(&display_map),
-                        end: selection.end.to_display_point(&display_map),
-                    });
+                    .push(SelectionLayout::from(selection, line_mode, &display_map));
             }
             selections.extend(remote_selections);
 
@@ -981,15 +1003,15 @@ impl Element for EditorElement {
                 let local_replica_id = view.leader_replica_id.unwrap_or(view.replica_id(cx));
 
                 selections.push((
-                    (local_replica_id, view.selections.line_mode),
+                    local_replica_id,
                     local_selections
                         .into_iter()
-                        .map(|selection| crate::Selection {
-                            id: selection.id,
-                            goal: selection.goal,
-                            reversed: selection.reversed,
-                            start: selection.start.to_display_point(&display_map),
-                            end: selection.end.to_display_point(&display_map),
+                        .map(|selection| {
+                            SelectionLayout::from(
+                                selection,
+                                view.selections.line_mode,
+                                &display_map,
+                            )
                         })
                         .collect(),
                 ));
@@ -1240,7 +1262,7 @@ pub struct LayoutState {
     em_width: f32,
     em_advance: f32,
     highlighted_ranges: Vec<(Range<DisplayPoint>, Color)>,
-    selections: Vec<((ReplicaId, bool), Vec<text::Selection<DisplayPoint>>)>,
+    selections: Vec<(ReplicaId, Vec<SelectionLayout>)>,
     context_menu: Option<(DisplayPoint, ElementBox)>,
     code_actions_indicator: Option<(u32, ElementBox)>,
 }

crates/editor/src/selections_collection.rs 🔗

@@ -128,6 +128,20 @@ impl SelectionsCollection {
         .collect()
     }
 
+    // Returns all of the selections, adjusted to take into account the selection line_mode
+    pub fn all_adjusted(&self, cx: &mut MutableAppContext) -> Vec<Selection<Point>> {
+        let mut selections = self.all::<Point>(cx);
+        if self.line_mode {
+            let map = self.display_map(cx);
+            for selection in &mut selections {
+                let new_range = map.expand_to_line(selection.range());
+                selection.start = new_range.start;
+                selection.end = new_range.end;
+            }
+        }
+        selections
+    }
+
     pub fn disjoint_in_range<'a, D>(
         &self,
         range: Range<Anchor>,

crates/gpui/src/app.rs 🔗

@@ -755,7 +755,7 @@ type SubscriptionCallback = Box<dyn FnMut(&dyn Any, &mut MutableAppContext) -> b
 type GlobalSubscriptionCallback = Box<dyn FnMut(&dyn Any, &mut MutableAppContext)>;
 type ObservationCallback = Box<dyn FnMut(&mut MutableAppContext) -> bool>;
 type FocusObservationCallback = Box<dyn FnMut(&mut MutableAppContext) -> bool>;
-type GlobalObservationCallback = Box<dyn FnMut(&dyn Any, &mut MutableAppContext)>;
+type GlobalObservationCallback = Box<dyn FnMut(&mut MutableAppContext)>;
 type ReleaseObservationCallback = Box<dyn FnOnce(&dyn Any, &mut MutableAppContext)>;
 type DeserializeActionCallback = fn(json: &str) -> anyhow::Result<Box<dyn Action>>;
 
@@ -1263,7 +1263,7 @@ impl MutableAppContext {
     pub fn observe_global<G, F>(&mut self, mut observe: F) -> Subscription
     where
         G: Any,
-        F: 'static + FnMut(&G, &mut MutableAppContext),
+        F: 'static + FnMut(&mut MutableAppContext),
     {
         let type_id = TypeId::of::<G>();
         let id = post_inc(&mut self.next_subscription_id);
@@ -1274,11 +1274,8 @@ impl MutableAppContext {
             .or_default()
             .insert(
                 id,
-                Some(
-                    Box::new(move |global: &dyn Any, cx: &mut MutableAppContext| {
-                        observe(global.downcast_ref().unwrap(), cx)
-                    }) as GlobalObservationCallback,
-                ),
+                Some(Box::new(move |cx: &mut MutableAppContext| observe(cx))
+                    as GlobalObservationCallback),
             );
 
         Subscription::GlobalObservation {
@@ -2261,27 +2258,24 @@ impl MutableAppContext {
     fn handle_global_notification_effect(&mut self, observed_type_id: TypeId) {
         let callbacks = self.global_observations.lock().remove(&observed_type_id);
         if let Some(callbacks) = callbacks {
-            if let Some(global) = self.cx.globals.remove(&observed_type_id) {
-                for (id, callback) in callbacks {
-                    if let Some(mut callback) = callback {
-                        callback(global.as_ref(), self);
-                        match self
-                            .global_observations
-                            .lock()
-                            .entry(observed_type_id)
-                            .or_default()
-                            .entry(id)
-                        {
-                            collections::btree_map::Entry::Vacant(entry) => {
-                                entry.insert(Some(callback));
-                            }
-                            collections::btree_map::Entry::Occupied(entry) => {
-                                entry.remove();
-                            }
+            for (id, callback) in callbacks {
+                if let Some(mut callback) = callback {
+                    callback(self);
+                    match self
+                        .global_observations
+                        .lock()
+                        .entry(observed_type_id)
+                        .or_default()
+                        .entry(id)
+                    {
+                        collections::btree_map::Entry::Vacant(entry) => {
+                            entry.insert(Some(callback));
+                        }
+                        collections::btree_map::Entry::Occupied(entry) => {
+                            entry.remove();
                         }
                     }
                 }
-                self.cx.globals.insert(observed_type_id, global);
             }
         }
     }
@@ -5599,7 +5593,7 @@ mod tests {
         let observation_count = Rc::new(RefCell::new(0));
         let subscription = cx.observe_global::<Global, _>({
             let observation_count = observation_count.clone();
-            move |_, _| {
+            move |_| {
                 *observation_count.borrow_mut() += 1;
             }
         });
@@ -5629,7 +5623,7 @@ mod tests {
         let observation_count = Rc::new(RefCell::new(0));
         cx.observe_global::<OtherGlobal, _>({
             let observation_count = observation_count.clone();
-            move |_, _| {
+            move |_| {
                 *observation_count.borrow_mut() += 1;
             }
         })
@@ -6003,7 +5997,7 @@ mod tests {
         *subscription.borrow_mut() = Some(cx.observe_global::<(), _>({
             let observation_count = observation_count.clone();
             let subscription = subscription.clone();
-            move |_, _| {
+            move |_| {
                 subscription.borrow_mut().take();
                 *observation_count.borrow_mut() += 1;
             }

crates/vim/src/motion.rs 🔗

@@ -193,11 +193,13 @@ impl Motion {
                 if selection.end.row() < map.max_point().row() {
                     *selection.end.row_mut() += 1;
                     *selection.end.column_mut() = 0;
+                    selection.end = map.clip_point(selection.end, Bias::Right);
                     // Don't reset the end here
                     return;
                 } else if selection.start.row() > 0 {
                     *selection.start.row_mut() -= 1;
                     *selection.start.column_mut() = map.line_len(selection.start.row());
+                    selection.start = map.clip_point(selection.start, Bias::Left);
                 }
             }
 

crates/vim/src/normal.rs 🔗

@@ -1,5 +1,6 @@
 mod change;
 mod delete;
+mod yank;
 
 use std::borrow::Cow;
 
@@ -15,7 +16,7 @@ use gpui::{actions, MutableAppContext, ViewContext};
 use language::{Point, SelectionGoal};
 use workspace::Workspace;
 
-use self::{change::change_over, delete::delete_over};
+use self::{change::change_over, delete::delete_over, yank::yank_over};
 
 actions!(
     vim,
@@ -69,11 +70,12 @@ pub fn normal_motion(motion: Motion, cx: &mut MutableAppContext) {
     Vim::update(cx, |vim, cx| {
         match vim.state.operator_stack.pop() {
             None => move_cursor(vim, motion, cx),
-            Some(Operator::Change) => change_over(vim, motion, cx),
-            Some(Operator::Delete) => delete_over(vim, motion, cx),
             Some(Operator::Namespace(_)) => {
                 // Can't do anything for a namespace operator. Ignoring
             }
+            Some(Operator::Change) => change_over(vim, motion, cx),
+            Some(Operator::Delete) => delete_over(vim, motion, cx),
+            Some(Operator::Yank) => yank_over(vim, motion, cx),
         }
         vim.clear_operator(cx);
     });

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

@@ -0,0 +1,26 @@
+use crate::{motion::Motion, utils::copy_selections_content, Vim};
+use collections::HashMap;
+use gpui::MutableAppContext;
+
+pub fn yank_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) {
+    vim.update_active_editor(cx, |editor, cx| {
+        editor.transact(cx, |editor, cx| {
+            editor.set_clip_at_line_ends(false, cx);
+            let mut original_positions: HashMap<_, _> = Default::default();
+            editor.change_selections(None, cx, |s| {
+                s.move_with(|map, selection| {
+                    let original_position = (selection.head(), selection.goal);
+                    motion.expand_selection(map, selection, true);
+                    original_positions.insert(selection.id, original_position);
+                });
+            });
+            copy_selections_content(editor, motion.linewise(), cx);
+            editor.change_selections(None, cx, |s| {
+                s.move_with(|_, selection| {
+                    let (head, goal) = original_positions.remove(&selection.id).unwrap();
+                    selection.collapse_to(head, goal);
+                });
+            });
+        });
+    });
+}

crates/vim/src/state.rs 🔗

@@ -26,6 +26,7 @@ pub enum Operator {
     Namespace(Namespace),
     Change,
     Delete,
+    Yank,
 }
 
 #[derive(Default)]
@@ -80,6 +81,7 @@ impl Operator {
             Operator::Namespace(Namespace::G) => "g",
             Operator::Change => "c",
             Operator::Delete => "d",
+            Operator::Yank => "y",
         }
         .to_owned();
 

crates/vim/src/vim.rs 🔗

@@ -42,8 +42,10 @@ pub fn init(cx: &mut MutableAppContext) {
         },
     );
 
-    cx.observe_global::<Settings, _>(|settings, cx| {
-        Vim::update(cx, |state, cx| state.set_enabled(settings.vim_mode, cx))
+    cx.observe_global::<Settings, _>(|cx| {
+        Vim::update(cx, |state, cx| {
+            state.set_enabled(cx.global::<Settings>().vim_mode, cx)
+        })
     })
     .detach();
 }
@@ -141,14 +143,11 @@ impl Vim {
                     }
 
                     if state.empty_selections_only() {
-                        // Defer so that access to global settings object doesn't panic
-                        cx.defer(|editor, cx| {
-                            editor.change_selections(None, cx, |s| {
-                                s.move_with(|_, selection| {
-                                    selection.collapse_to(selection.head(), selection.goal)
-                                });
-                            })
-                        });
+                        editor.change_selections(None, cx, |s| {
+                            s.move_with(|_, selection| {
+                                selection.collapse_to(selection.head(), selection.goal)
+                            });
+                        })
                     }
                 });
             }

crates/vim/src/vim_test_context.rs 🔗

@@ -337,6 +337,14 @@ impl<'a> VimTestContext<'a> {
         let mode = self.mode();
         VimBindingTestContext::new(keystrokes, mode, mode, self)
     }
+
+    pub fn assert_clipboard_content(&mut self, expected_content: Option<&str>) {
+        self.cx.update(|cx| {
+            let actual_content = cx.read_from_clipboard().map(|item| item.text().to_owned());
+            let expected_content = expected_content.map(|content| content.to_owned());
+            assert_eq!(actual_content, expected_content);
+        })
+    }
 }
 
 impl<'a> Deref for VimTestContext<'a> {

crates/vim/src/visual.rs 🔗

@@ -9,9 +9,11 @@ actions!(
     vim,
     [
         VisualDelete,
-        VisualChange,
         VisualLineDelete,
-        VisualLineChange
+        VisualChange,
+        VisualLineChange,
+        VisualYank,
+        VisualLineYank,
     ]
 );
 
@@ -20,6 +22,8 @@ pub fn init(cx: &mut MutableAppContext) {
     cx.add_action(change_line);
     cx.add_action(delete);
     cx.add_action(delete_line);
+    cx.add_action(yank);
+    cx.add_action(yank_line);
 }
 
 pub fn visual_motion(motion: Motion, cx: &mut MutableAppContext) {
@@ -56,8 +60,8 @@ pub fn change(_: &mut Workspace, _: &VisualChange, cx: &mut ViewContext<Workspac
             editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
                 s.move_with(|map, selection| {
                     if !selection.reversed {
-                        // Head was at the end of the selection, and now is at the start. We need to move the end
-                        // forward by one if possible in order to compensate for this change.
+                        // Head is at the end of the selection. Adjust the end position to
+                        // to include the character under the cursor.
                         *selection.end.column_mut() = selection.end.column() + 1;
                         selection.end = map.clip_point(selection.end, Bias::Left);
                     }
@@ -74,12 +78,9 @@ pub fn change_line(_: &mut Workspace, _: &VisualLineChange, cx: &mut ViewContext
     Vim::update(cx, |vim, cx| {
         vim.update_active_editor(cx, |editor, cx| {
             editor.set_clip_at_line_ends(false, cx);
-            editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
-                s.move_with(|map, selection| {
-                    selection.start = map.prev_line_boundary(selection.start.to_point(map)).1;
-                    selection.end = map.next_line_boundary(selection.end.to_point(map)).1;
-                });
-            });
+
+            let adjusted = editor.selections.all_adjusted(cx);
+            editor.change_selections(None, cx, |s| s.select(adjusted));
             copy_selections_content(editor, true, cx);
             editor.insert("", cx);
         });
@@ -131,11 +132,13 @@ pub fn delete_line(_: &mut Workspace, _: &VisualLineDelete, cx: &mut ViewContext
                     if selection.end.row() < map.max_point().row() {
                         *selection.end.row_mut() += 1;
                         *selection.end.column_mut() = 0;
+                        selection.end = map.clip_point(selection.end, Bias::Right);
                         // Don't reset the end here
                         return;
                     } else if selection.start.row() > 0 {
                         *selection.start.row_mut() -= 1;
                         *selection.start.column_mut() = map.line_len(selection.start.row());
+                        selection.start = map.clip_point(selection.start, Bias::Left);
                     }
 
                     selection.end = map.next_line_boundary(selection.end.to_point(map)).1;
@@ -161,6 +164,38 @@ pub fn delete_line(_: &mut Workspace, _: &VisualLineDelete, cx: &mut ViewContext
     });
 }
 
+pub fn yank(_: &mut Workspace, _: &VisualYank, cx: &mut ViewContext<Workspace>) {
+    Vim::update(cx, |vim, cx| {
+        vim.update_active_editor(cx, |editor, cx| {
+            editor.set_clip_at_line_ends(false, cx);
+            editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
+                s.move_with(|map, selection| {
+                    if !selection.reversed {
+                        // Head is at the end of the selection. Adjust the end position to
+                        // to include the character under the cursor.
+                        *selection.end.column_mut() = selection.end.column() + 1;
+                        selection.end = map.clip_point(selection.end, Bias::Left);
+                    }
+                });
+            });
+            copy_selections_content(editor, false, cx);
+        });
+        vim.switch_mode(Mode::Normal, cx);
+    });
+}
+
+pub fn yank_line(_: &mut Workspace, _: &VisualLineYank, cx: &mut ViewContext<Workspace>) {
+    Vim::update(cx, |vim, cx| {
+        vim.update_active_editor(cx, |editor, cx| {
+            editor.set_clip_at_line_ends(false, cx);
+            let adjusted = editor.selections.all_adjusted(cx);
+            editor.change_selections(None, cx, |s| s.select(adjusted));
+            copy_selections_content(editor, true, cx);
+        });
+        vim.switch_mode(Mode::Normal, cx);
+    });
+}
+
 #[cfg(test)]
 mod test {
     use indoc::indoc;
@@ -521,4 +556,88 @@ mod test {
                 |"},
         );
     }
+
+    #[gpui::test]
+    async fn test_visual_yank(cx: &mut gpui::TestAppContext) {
+        let cx = VimTestContext::new(cx, true).await;
+        let mut cx = cx.binding(["v", "w", "y"]);
+        cx.assert("The quick |brown", "The quick |brown");
+        cx.assert_clipboard_content(Some("brown"));
+        let mut cx = cx.binding(["v", "w", "j", "y"]);
+        cx.assert(
+            indoc! {"
+                The |quick brown
+                fox jumps over
+                the lazy dog"},
+            indoc! {"
+                The |quick brown
+                fox jumps over
+                the lazy dog"},
+        );
+        cx.assert_clipboard_content(Some(indoc! {"
+            quick brown
+            fox jumps ov"}));
+        cx.assert(
+            indoc! {"
+                The quick brown
+                fox jumps over
+                the |lazy dog"},
+            indoc! {"
+                The quick brown
+                fox jumps over
+                the |lazy dog"},
+        );
+        cx.assert_clipboard_content(Some("lazy d"));
+        cx.assert(
+            indoc! {"
+                The quick brown
+                fox jumps |over
+                the lazy dog"},
+            indoc! {"
+                The quick brown
+                fox jumps |over
+                the lazy dog"},
+        );
+        cx.assert_clipboard_content(Some(indoc! {"
+                over
+                t"}));
+        let mut cx = cx.binding(["v", "b", "k", "y"]);
+        cx.assert(
+            indoc! {"
+                The |quick brown
+                fox jumps over
+                the lazy dog"},
+            indoc! {"
+                The |quick brown
+                fox jumps over
+                the lazy dog"},
+        );
+        cx.assert_clipboard_content(Some("The q"));
+        cx.assert(
+            indoc! {"
+                The quick brown
+                fox jumps over
+                the |lazy dog"},
+            indoc! {"
+                The quick brown
+                fox jumps over
+                the |lazy dog"},
+        );
+        cx.assert_clipboard_content(Some(indoc! {"
+            fox jumps over
+            the l"}));
+        cx.assert(
+            indoc! {"
+                The quick brown
+                fox jumps |over
+                the lazy dog"},
+            indoc! {"
+                The quick brown
+                fox jumps |over
+                the lazy dog"},
+        );
+        cx.assert_clipboard_content(Some(indoc! {"
+            quick brown
+            fox jumps o"}));
+    }
 }

crates/zed/src/main.rs 🔗

@@ -179,8 +179,8 @@ fn main() {
 
         cx.observe_global::<Settings, _>({
             let languages = languages.clone();
-            move |settings, _| {
-                languages.set_theme(&settings.theme.editor.syntax);
+            move |cx| {
+                languages.set_theme(&cx.global::<Settings>().theme.editor.syntax);
             }
         })
         .detach();