diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 238ba4ffe5..b2107c7954 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -437,6 +437,7 @@ "context": "vim_operator == c", "bindings": { "c": "vim::CurrentLine", + "x": "vim::Exchange", "d": "editor::Rename", // zed specific "s": ["vim::PushChangeSurrounds", {}] } @@ -523,6 +524,19 @@ "c": "vim::CurrentLine" } }, + { + "context": "vim_operator == gr", + "bindings": { + "r": "vim::CurrentLine" + } + }, + { + "context": "vim_operator == cx", + "bindings": { + "x": "vim::CurrentLine", + "c": "vim::ClearExchange" + } + }, { "context": "vim_mode == literal", "bindings": { diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index d84285fad6..640e7121fd 100644 --- a/crates/vim/src/normal.rs +++ b/crates/vim/src/normal.rs @@ -185,6 +185,7 @@ impl Vim { Some(Operator::ReplaceWithRegister) => { self.replace_with_register_motion(motion, times, window, cx) } + Some(Operator::Exchange) => self.exchange_motion(motion, times, window, cx), Some(operator) => { // Can't do anything for text objects, Ignoring error!("Unexpected normal mode motion operator: {:?}", operator) @@ -234,6 +235,7 @@ impl Vim { Some(Operator::ReplaceWithRegister) => { self.replace_with_register_object(object, around, window, cx) } + Some(Operator::Exchange) => self.exchange_object(object, around, window, cx), _ => { // Can't do anything for namespace operators. Ignoring } diff --git a/crates/vim/src/replace.rs b/crates/vim/src/replace.rs index a3399678ba..77f8af1f47 100644 --- a/crates/vim/src/replace.rs +++ b/crates/vim/src/replace.rs @@ -1,11 +1,15 @@ use crate::{ - motion::{self}, + motion::{self, Motion}, + object::Object, state::Mode, Vim, }; -use editor::{display_map::ToDisplayPoint, Bias, Editor, ToPoint}; +use editor::{ + display_map::ToDisplayPoint, scroll::Autoscroll, Anchor, Bias, Editor, EditorSnapshot, + ToOffset, ToPoint, +}; use gpui::{actions, Context, Window}; -use language::Point; +use language::{Point, SelectionGoal}; use std::ops::Range; use std::sync::Arc; @@ -27,6 +31,8 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { }); } +struct VimExchange; + impl Vim { pub(crate) fn multi_replace( &mut self, @@ -124,6 +130,139 @@ impl Vim { }); }); } + + pub fn exchange_object( + &mut self, + object: Object, + around: bool, + window: &mut Window, + cx: &mut Context, + ) { + self.stop_recording(cx); + self.update_editor(window, cx, |vim, editor, window, cx| { + editor.set_clip_at_line_ends(false, cx); + let mut selection = editor.selections.newest_display(cx); + let snapshot = editor.snapshot(window, cx); + object.expand_selection(&snapshot, &mut selection, around); + let start = snapshot + .buffer_snapshot + .anchor_before(selection.start.to_point(&snapshot)); + let end = snapshot + .buffer_snapshot + .anchor_before(selection.end.to_point(&snapshot)); + let new_range = start..end; + vim.exchange_impl(new_range, editor, &snapshot, window, cx); + editor.set_clip_at_line_ends(true, cx); + }); + } + + pub fn exchange_visual(&mut self, window: &mut Window, cx: &mut Context) { + self.stop_recording(cx); + self.update_editor(window, cx, |vim, editor, window, cx| { + let selection = editor.selections.newest_anchor(); + let new_range = selection.start..selection.end; + let snapshot = editor.snapshot(window, cx); + vim.exchange_impl(new_range, editor, &snapshot, window, cx); + }); + self.switch_mode(Mode::Normal, false, window, cx); + } + + pub fn clear_exchange(&mut self, window: &mut Window, cx: &mut Context) { + self.stop_recording(cx); + self.update_editor(window, cx, |_, editor, _, cx| { + editor.clear_highlights::(cx); + }); + } + + pub fn exchange_motion( + &mut self, + motion: Motion, + times: Option, + window: &mut Window, + cx: &mut Context, + ) { + self.stop_recording(cx); + self.update_editor(window, cx, |vim, editor, window, cx| { + editor.set_clip_at_line_ends(false, cx); + let text_layout_details = editor.text_layout_details(window); + let mut selection = editor.selections.newest_display(cx); + let snapshot = editor.snapshot(window, cx); + motion.expand_selection( + &snapshot, + &mut selection, + times, + false, + &text_layout_details, + ); + let start = snapshot + .buffer_snapshot + .anchor_before(selection.start.to_point(&snapshot)); + let end = snapshot + .buffer_snapshot + .anchor_before(selection.end.to_point(&snapshot)); + let new_range = start..end; + vim.exchange_impl(new_range, editor, &snapshot, window, cx); + editor.set_clip_at_line_ends(true, cx); + }); + } + + pub fn exchange_impl( + &self, + new_range: Range, + editor: &mut Editor, + snapshot: &EditorSnapshot, + window: &mut Window, + cx: &mut Context, + ) { + if let Some((_, ranges)) = editor.clear_background_highlights::(cx) { + let previous_range = ranges[0].clone(); + + let new_range_start = new_range.start.to_offset(&snapshot.buffer_snapshot); + let new_range_end = new_range.end.to_offset(&snapshot.buffer_snapshot); + let previous_range_end = previous_range.end.to_offset(&snapshot.buffer_snapshot); + let previous_range_start = previous_range.start.to_offset(&snapshot.buffer_snapshot); + + let text_for = |range: Range| { + snapshot + .buffer_snapshot + .text_for_range(range) + .collect::() + }; + + let mut final_cursor_position = None; + + if previous_range_end < new_range_start || new_range_end < previous_range_start { + let previous_text = text_for(previous_range.clone()); + let new_text = text_for(new_range.clone()); + final_cursor_position = Some(new_range.start.to_display_point(snapshot)); + + editor.edit([(previous_range, new_text), (new_range, previous_text)], cx); + } else if new_range_start <= previous_range_start && new_range_end >= previous_range_end + { + final_cursor_position = Some(new_range.start.to_display_point(snapshot)); + editor.edit([(new_range, text_for(previous_range))], cx); + } else if previous_range_start <= new_range_start && previous_range_end >= new_range_end + { + final_cursor_position = Some(previous_range.start.to_display_point(snapshot)); + editor.edit([(previous_range, text_for(new_range))], cx); + } + + if let Some(position) = final_cursor_position { + editor.change_selections(Some(Autoscroll::fit()), window, cx, |s| { + s.move_with(|_map, selection| { + selection.collapse_to(position, SelectionGoal::None); + }); + }) + } + } else { + let ranges = [new_range]; + editor.highlight_background::( + &ranges, + |theme| theme.editor_document_highlight_read_background, + cx, + ); + } + } } #[cfg(test)] @@ -311,4 +450,37 @@ mod test { cx.simulate_keystrokes("0 shift-r b b b escape u"); cx.assert_state("ˇaaaa", Mode::Normal); } + + #[gpui::test] + async fn test_exchange_separate_range(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + cx.set_state("ˇhello world", Mode::Normal); + cx.simulate_keystrokes("c x i w w c x i w"); + cx.assert_state("world ˇhello", Mode::Normal); + } + + #[gpui::test] + async fn test_exchange_complete_overlap(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + cx.set_state("ˇhello world", Mode::Normal); + cx.simulate_keystrokes("c x x w c x i w"); + cx.assert_state("ˇworld", Mode::Normal); + + // the focus should still be at the start of the word if we reverse the + // order of selections (smaller -> larger) + cx.set_state("ˇhello world", Mode::Normal); + cx.simulate_keystrokes("c x i w c x x"); + cx.assert_state("ˇhello", Mode::Normal); + } + + #[gpui::test] + async fn test_exchange_partial_overlap(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + cx.set_state("ˇhello world", Mode::Normal); + cx.simulate_keystrokes("c x t r w c x i w"); + cx.assert_state("hello ˇworld", Mode::Normal); + } } diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index 563a40d3f8..a89b558c14 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -110,6 +110,7 @@ pub enum Operator { ReplayRegister, ToggleComments, ReplaceWithRegister, + Exchange, } #[derive(Default, Clone, Debug)] @@ -501,6 +502,7 @@ impl Operator { Operator::ShellCommand => "sh", Operator::Rewrap => "gq", Operator::ReplaceWithRegister => "gr", + Operator::Exchange => "cx", Operator::Outdent => "<", Operator::Uppercase => "gU", Operator::Lowercase => "gu", @@ -554,6 +556,7 @@ impl Operator { | Operator::Lowercase | Operator::Uppercase | Operator::ReplaceWithRegister + | Operator::Exchange | Operator::Object { .. } | Operator::ChangeSurrounds { target: None } | Operator::OppositeCase diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index babec185ca..e3cbcd9c40 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -126,6 +126,7 @@ actions!( SwitchToVisualBlockMode, SwitchToHelixNormalMode, ClearOperators, + ClearExchange, Tab, Enter, InnerObject, @@ -138,6 +139,7 @@ actions!( ResizePaneDown, PushChange, PushDelete, + Exchange, PushYank, PushReplace, PushDeleteSurrounds, @@ -637,6 +639,18 @@ impl Vim { }, ); + Vim::action(editor, cx, |vim, _: &Exchange, window, cx| { + if vim.mode.is_visual() { + vim.exchange_visual(window, cx) + } else { + vim.push_operator(Operator::Exchange, window, cx) + } + }); + + Vim::action(editor, cx, |vim, _: &ClearExchange, window, cx| { + vim.clear_exchange(window, cx) + }); + Vim::action(editor, cx, |vim, _: &PushToggleComments, window, cx| { vim.push_operator(Operator::ToggleComments, window, cx) }); diff --git a/docs/src/vim.md b/docs/src/vim.md index 7567b8d7c0..a84c8636fe 100644 --- a/docs/src/vim.md +++ b/docs/src/vim.md @@ -160,6 +160,7 @@ Zed's vim mode includes some features that are usually provided by very popular - The project panel supports many shortcuts modeled after the Vim plugin `netrw`: navigation with `hjkl`, open file with `o`, open file in a new tab with `t`, etc. - You can add key bindings to your keymap to navigate "camelCase" names. [Head down to the Optional key bindings](#optional-key-bindings) section to learn how. - You can use `gr` to do [ReplaceWithRegister](https://github.com/vim-scripts/ReplaceWithRegister). +- You can use `cx` for [vim-exchange](https://github.com/tommcdo/vim-exchange) functionality. Note that it does not have a default binding in visual mode, but you can add one to your keymap (refer to the [optional key bindings](#optional-key-bindings) section). ## Command palette @@ -414,6 +415,17 @@ The [Sneak motion](https://github.com/justinmk/vim-sneak) feature allows for qui } ``` +The [vim-exchange](https://github.com/tommcdo/vim-exchange) feature does not have a default binding for visual mode, as the `shift-x` binding conflicts with the default `shift-x` binding for visual mode (`vim::VisualDeleteLine`). To assign the default vim-exchange binding, add the following keybinding to your keymap: + +```json +{ + "context": "vim_mode == visual", + "bindings": { + "shift-x": "vim::Exchange" + } +} +``` + ### Restoring common text editing keybindings If you're using vim mode on Linux or Windows, you may find it overrides keybindings you can't live without: `ctrl+v` to paste, `ctrl+f` to search, etc. You can restore them by copying this data into your keymap: