diff --git a/.github/workflows/discord_webhook.yml b/.github/workflows/discord_webhook.yml deleted file mode 100644 index b71d451f5b..0000000000 --- a/.github/workflows/discord_webhook.yml +++ /dev/null @@ -1,22 +0,0 @@ -on: - release: - types: [published] - -jobs: - message: - runs-on: ubuntu-latest - steps: - - name: Discord Webhook Action - uses: tsickert/discord-webhook@v5.3.0 - with: - webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }} - content: | - 📣 Zed ${{ github.event.release.name }} was just released! - - Restart your Zed or head to https://zed.dev/releases to grab it. - - ```md - ### Changelog - - ${{ github.event.release.body }} - ``` \ No newline at end of file diff --git a/.github/workflows/release_actions.yml b/.github/workflows/release_actions.yml new file mode 100644 index 0000000000..9a3b2376df --- /dev/null +++ b/.github/workflows/release_actions.yml @@ -0,0 +1,33 @@ +on: + release: + types: [published] + +jobs: + discord_release: + runs-on: ubuntu-latest + steps: + - name: Discord Webhook Action + uses: tsickert/discord-webhook@v5.3.0 + with: + webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }} + content: | + 📣 Zed ${{ github.event.release.tag_name }} was just released! + + Restart your Zed or head to https://zed.dev/releases to grab it. + + ```md + ### Changelog + + ${{ github.event.release.body }} + ``` + amplitude_release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: "3.10.5" + architecture: "x64" + cache: "pip" + - run: pip install -r script/amplitude_release/requirements.txt + - run: python script/amplitude_release/main.py ${{ github.event.release.tag_name }} ${{ secrets.ZED_AMPLITUDE_API_KEY }} ${{ secrets.ZED_AMPLITUDE_SECRET_KEY }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5e6963ba8b..2d721f8ad2 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,5 @@ /vendor/bin /assets/themes/*.json /assets/themes/internal/*.json -/assets/themes/experiments/*.json \ No newline at end of file +/assets/themes/experiments/*.json +**/venv \ No newline at end of file diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 5262daab5f..94729af21f 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -9,11 +9,10 @@ } ], "h": "vim::Left", - "backspace": "vim::Left", + "backspace": "vim::Backspace", "j": "vim::Down", "k": "vim::Up", "l": "vim::Right", - "0": "vim::StartOfLine", "$": "vim::EndOfLine", "shift-g": "vim::EndOfDocument", "w": "vim::NextWordStart", @@ -38,7 +37,60 @@ } ], "%": "vim::Matching", - "escape": "editor::Cancel" + "escape": "editor::Cancel", + "i": [ + "vim::PushOperator", + { + "Object": { + "around": false + } + } + ], + "a": [ + "vim::PushOperator", + { + "Object": { + "around": true + } + } + ], + "0": "vim::StartOfLine", // When no number operator present, use start of line motion + "1": [ + "vim::Number", + 1 + ], + "2": [ + "vim::Number", + 2 + ], + "3": [ + "vim::Number", + 3 + ], + "4": [ + "vim::Number", + 4 + ], + "5": [ + "vim::Number", + 5 + ], + "6": [ + "vim::Number", + 6 + ], + "7": [ + "vim::Number", + 7 + ], + "8": [ + "vim::Number", + 8 + ], + "9": [ + "vim::Number", + 9 + ] } }, { @@ -98,6 +150,15 @@ ] } }, + { + "context": "Editor && vim_operator == n", + "bindings": { + "0": [ + "vim::Number", + 0 + ] + } + }, { "context": "Editor && vim_operator == g", "bindings": { @@ -112,13 +173,6 @@ { "context": "Editor && vim_operator == c", "bindings": { - "w": "vim::ChangeWord", - "shift-w": [ - "vim::ChangeWord", - { - "ignorePunctuation": true - } - ], "c": "vim::CurrentLine" } }, @@ -134,9 +188,34 @@ "y": "vim::CurrentLine" } }, + { + "context": "Editor && VimObject", + "bindings": { + "w": "vim::Word", + "shift-w": [ + "vim::Word", + { + "ignorePunctuation": true + } + ], + "s": "vim::Sentence", + "'": "vim::Quotes", + "`": "vim::BackQuotes", + "\"": "vim::DoubleQuotes", + "(": "vim::Parentheses", + ")": "vim::Parentheses", + "[": "vim::SquareBrackets", + "]": "vim::SquareBrackets", + "{": "vim::CurlyBrackets", + "}": "vim::CurlyBrackets", + "<": "vim::AngleBrackets", + ">": "vim::AngleBrackets" + } + }, { "context": "Editor && vim_mode == visual", "bindings": { + "u": "editor::Undo", "c": "vim::VisualChange", "d": "vim::VisualDelete", "x": "vim::VisualDelete", diff --git a/crates/call/src/room.rs b/crates/call/src/room.rs index 572f512d1c..5003479214 100644 --- a/crates/call/src/room.rs +++ b/crates/call/src/room.rs @@ -398,11 +398,11 @@ impl Room { cx.spawn(|this, mut cx| async move { let response = request.await?; - project - .update(&mut cx, |project, cx| { - project.shared(response.project_id, cx) - }) - .await?; + project.update(&mut cx, |project, cx| { + project + .shared(response.project_id, cx) + .detach_and_log_err(cx) + }); // If the user's location is in this project, it changes from UnsharedProject to SharedProject. this.update(&mut cx, |this, cx| { diff --git a/crates/collab/src/integration_tests.rs b/crates/collab/src/integration_tests.rs index a0a6f5feea..f6f0f5c7f2 100644 --- a/crates/collab/src/integration_tests.rs +++ b/crates/collab/src/integration_tests.rs @@ -3874,6 +3874,7 @@ async fn test_language_server_statuses( .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) .await .unwrap(); + deterministic.run_until_parked(); let project_b = client_b.build_remote_project(project_id, cx_b).await; project_b.read_with(cx_b, |project, _| { let status = project.language_server_statuses().next().unwrap(); @@ -5522,6 +5523,7 @@ async fn test_random_collaboration( cx.font_cache(), cx.leak_detector(), next_entity_id, + cx.function_name.clone(), ); let host = server.create_client(&mut host_cx, "host").await; let host_project = host_cx.update(|cx| { @@ -5763,6 +5765,7 @@ async fn test_random_collaboration( cx.font_cache(), cx.leak_detector(), next_entity_id, + cx.function_name.clone(), ); deterministic.start_waiting(); diff --git a/crates/collab_ui/src/contact_list.rs b/crates/collab_ui/src/contact_list.rs index 7b773240cf..c04f0fe72d 100644 --- a/crates/collab_ui/src/contact_list.rs +++ b/crates/collab_ui/src/contact_list.rs @@ -65,7 +65,6 @@ enum ContactEntry { project_id: u64, worktree_root_names: Vec, host_user_id: u64, - is_host: bool, is_last: bool, }, IncomingRequest(Arc), @@ -181,6 +180,7 @@ impl ContactList { let list_state = ListState::new(0, Orientation::Top, 1000., cx, move |this, ix, cx| { let theme = cx.global::().theme.clone(); let is_selected = this.selection == Some(ix); + let current_project_id = this.project.read(cx).remote_id(); match &this.entries[ix] { ContactEntry::Header(section) => { @@ -205,13 +205,12 @@ impl ContactList { project_id, worktree_root_names, host_user_id, - is_host, is_last, } => Self::render_participant_project( *project_id, worktree_root_names, *host_user_id, - *is_host, + Some(*project_id) == current_project_id, *is_last, is_selected, &theme.contact_list, @@ -341,15 +340,12 @@ impl ContactList { ContactEntry::ParticipantProject { project_id, host_user_id, - is_host, .. } => { - if !is_host { - cx.dispatch_global_action(JoinProject { - project_id: *project_id, - follow_user_id: *host_user_id, - }); - } + cx.dispatch_global_action(JoinProject { + project_id: *project_id, + follow_user_id: *host_user_id, + }); } _ => {} } @@ -407,7 +403,6 @@ impl ContactList { project_id: project.id, worktree_root_names: project.worktree_root_names.clone(), host_user_id: user_id, - is_host: true, is_last: projects.peek().is_none(), }); } @@ -448,7 +443,6 @@ impl ContactList { project_id: project.id, worktree_root_names: project.worktree_root_names.clone(), host_user_id: participant.user.id, - is_host: false, is_last: projects.peek().is_none(), }); } @@ -667,7 +661,7 @@ impl ContactList { project_id: u64, worktree_root_names: &[String], host_user_id: u64, - is_host: bool, + is_current: bool, is_last: bool, is_selected: bool, theme: &theme::ContactList, @@ -749,13 +743,13 @@ impl ContactList { .with_style(row.container) .boxed() }) - .with_cursor_style(if !is_host { + .with_cursor_style(if !is_current { CursorStyle::PointingHand } else { CursorStyle::Arrow }) .on_click(MouseButton::Left, move |_, cx| { - if !is_host { + if !is_current { cx.dispatch_global_action(JoinProject { project_id, follow_user_id: host_user_id, diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index 525af0d599..91a3a30267 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -331,34 +331,91 @@ impl DisplaySnapshot { DisplayPoint(self.blocks_snapshot.max_point()) } + /// Returns text chunks starting at the given display row until the end of the file pub fn text_chunks(&self, display_row: u32) -> impl Iterator { self.blocks_snapshot .chunks(display_row..self.max_point().row() + 1, false, None) .map(|h| h.text) } + // Returns text chunks starting at the end of the given display row in reverse until the start of the file + pub fn reverse_text_chunks(&self, display_row: u32) -> impl Iterator { + (0..=display_row).into_iter().rev().flat_map(|row| { + self.blocks_snapshot + .chunks(row..row + 1, false, None) + .map(|h| h.text) + .collect::>() + .into_iter() + .rev() + }) + } + pub fn chunks(&self, display_rows: Range, language_aware: bool) -> DisplayChunks<'_> { self.blocks_snapshot .chunks(display_rows, language_aware, Some(&self.text_highlights)) } - pub fn chars_at(&self, point: DisplayPoint) -> impl Iterator + '_ { - let mut column = 0; - let mut chars = self.text_chunks(point.row()).flat_map(str::chars); - while column < point.column() { - if let Some(c) = chars.next() { - column += c.len_utf8() as u32; - } else { - break; - } - } - chars + pub fn chars_at( + &self, + mut point: DisplayPoint, + ) -> impl Iterator + '_ { + point = DisplayPoint(self.blocks_snapshot.clip_point(point.0, Bias::Left)); + self.text_chunks(point.row()) + .flat_map(str::chars) + .skip_while({ + let mut column = 0; + move |char| { + let at_point = column >= point.column(); + column += char.len_utf8() as u32; + !at_point + } + }) + .map(move |ch| { + let result = (ch, point); + if ch == '\n' { + *point.row_mut() += 1; + *point.column_mut() = 0; + } else { + *point.column_mut() += ch.len_utf8() as u32; + } + result + }) + } + + pub fn reverse_chars_at( + &self, + mut point: DisplayPoint, + ) -> impl Iterator + '_ { + point = DisplayPoint(self.blocks_snapshot.clip_point(point.0, Bias::Left)); + self.reverse_text_chunks(point.row()) + .flat_map(|chunk| chunk.chars().rev()) + .skip_while({ + let mut column = self.line_len(point.row()); + if self.max_point().row() > point.row() { + column += 1; + } + + move |char| { + let at_point = column <= point.column(); + column = column.saturating_sub(char.len_utf8() as u32); + !at_point + } + }) + .map(move |ch| { + if ch == '\n' { + *point.row_mut() -= 1; + *point.column_mut() = self.line_len(point.row()); + } else { + *point.column_mut() = point.column().saturating_sub(ch.len_utf8() as u32); + } + (ch, point) + }) } pub fn column_to_chars(&self, display_row: u32, target: u32) -> u32 { let mut count = 0; let mut column = 0; - for c in self.chars_at(DisplayPoint::new(display_row, 0)) { + for (c, _) in self.chars_at(DisplayPoint::new(display_row, 0)) { if column >= target { break; } @@ -371,7 +428,7 @@ impl DisplaySnapshot { pub fn column_from_chars(&self, display_row: u32, char_count: u32) -> u32 { let mut column = 0; - for (count, c) in self.chars_at(DisplayPoint::new(display_row, 0)).enumerate() { + for (count, (c, _)) in self.chars_at(DisplayPoint::new(display_row, 0)).enumerate() { if c == '\n' || count >= char_count as usize { break; } @@ -455,7 +512,7 @@ impl DisplaySnapshot { pub fn line_indent(&self, display_row: u32) -> (u32, bool) { let mut indent = 0; let mut is_blank = true; - for c in self.chars_at(DisplayPoint::new(display_row, 0)) { + for (c, _) in self.chars_at(DisplayPoint::new(display_row, 0)) { if c == ' ' { indent += 1; } else { diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 5f424cd20b..c8bb16ee00 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -77,6 +77,7 @@ use util::{post_inc, ResultExt, TryFutureExt}; use workspace::{ItemNavHistory, Workspace}; const CURSOR_BLINK_INTERVAL: Duration = Duration::from_millis(500); +const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1); const MAX_LINE_LEN: usize = 1024; const MIN_NAVIGATION_HISTORY_ROW_DELTA: i64 = 10; const MAX_SELECTION_HISTORY_LEN: usize = 1024; @@ -239,6 +240,9 @@ pub enum Direction { Next, } +#[derive(Default)] +struct ScrollbarAutoHide(bool); + pub fn init(cx: &mut MutableAppContext) { cx.add_action(Editor::new_file); cx.add_action(|this: &mut Editor, action: &Scroll, cx| this.set_scroll_position(action.0, cx)); @@ -428,6 +432,8 @@ pub struct Editor { focused: bool, show_local_cursors: bool, show_local_selections: bool, + show_scrollbars: bool, + hide_scrollbar_task: Option>, blink_epoch: usize, blinking_paused: bool, mode: EditorMode, @@ -1030,6 +1036,8 @@ impl Editor { focused: false, show_local_cursors: false, show_local_selections: true, + show_scrollbars: true, + hide_scrollbar_task: None, blink_epoch: 0, blinking_paused: false, mode, @@ -1062,10 +1070,16 @@ impl Editor { ], }; this.end_selection(cx); + this.make_scrollbar_visible(cx); let editor_created_event = EditorCreated(cx.handle()); cx.emit_global(editor_created_event); + if mode == EditorMode::Full { + let should_auto_hide_scrollbars = cx.platform().should_auto_hide_scrollbars(); + cx.set_global(ScrollbarAutoHide(should_auto_hide_scrollbars)); + } + this.report_event("open editor", cx); this } @@ -1182,6 +1196,7 @@ impl Editor { self.scroll_top_anchor = anchor; } + self.make_scrollbar_visible(cx); self.autoscroll_request.take(); hide_hover(self, cx); @@ -1257,7 +1272,7 @@ impl Editor { let max_scroll_top = if matches!(self.mode, EditorMode::AutoHeight { .. }) { (display_map.max_point().row() as f32 - visible_lines + 1.).max(0.) } else { - display_map.max_point().row().saturating_sub(1) as f32 + display_map.max_point().row() as f32 }; if scroll_position.y() > max_scroll_top { scroll_position.set_y(max_scroll_top); @@ -4081,7 +4096,7 @@ impl Editor { self.change_selections(Some(Autoscroll::Fit), cx, |s| { s.move_cursors_with(|map, head, _| { ( - movement::line_beginning(map, head, true), + movement::indented_line_beginning(map, head, true), SelectionGoal::None, ) }); @@ -4096,7 +4111,7 @@ impl Editor { self.change_selections(Some(Autoscroll::Fit), cx, |s| { s.move_heads_with(|map, head, _| { ( - movement::line_beginning(map, head, action.stop_at_soft_wraps), + movement::indented_line_beginning(map, head, action.stop_at_soft_wraps), SelectionGoal::None, ) }); @@ -5953,6 +5968,31 @@ impl Editor { self.show_local_cursors && self.focused } + pub fn show_scrollbars(&self) -> bool { + self.show_scrollbars + } + + fn make_scrollbar_visible(&mut self, cx: &mut ViewContext) { + if !self.show_scrollbars { + self.show_scrollbars = true; + cx.notify(); + } + + if cx.default_global::().0 { + self.hide_scrollbar_task = Some(cx.spawn_weak(|this, mut cx| async move { + Timer::after(SCROLLBAR_SHOW_INTERVAL).await; + if let Some(this) = this.upgrade(&cx) { + this.update(&mut cx, |this, cx| { + this.show_scrollbars = false; + cx.notify(); + }); + } + })); + } else { + self.hide_scrollbar_task = None; + } + } + fn on_buffer_changed(&mut self, _: ModelHandle, cx: &mut ViewContext) { cx.notify(); } diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index e152c85e4e..8b41990574 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -1,20 +1,22 @@ +use std::{cell::RefCell, rc::Rc, time::Instant}; + +use futures::StreamExt; +use indoc::indoc; +use unindent::Unindent; + use super::*; use crate::test::{ - assert_text_with_selections, build_editor, select_ranges, EditorLspTestContext, - EditorTestContext, + assert_text_with_selections, build_editor, editor_lsp_test_context::EditorLspTestContext, + editor_test_context::EditorTestContext, select_ranges, }; -use futures::StreamExt; use gpui::{ geometry::rect::RectF, platform::{WindowBounds, WindowOptions}, }; -use indoc::indoc; use language::{FakeLspAdapter, LanguageConfig, LanguageRegistry}; use project::FakeFs; use rope::point::Point; use settings::EditorSettings; -use std::{cell::RefCell, rc::Rc, time::Instant}; -use unindent::Unindent; use util::{ assert_set_eq, test::{marked_text_ranges, marked_text_ranges_by, sample_text, TextRangeMarker}, diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 114a1d72c9..2842928914 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -44,7 +44,7 @@ use std::{ cmp::{self, Ordering}, fmt::Write, iter, - ops::Range, + ops::{DerefMut, Range}, sync::Arc, }; use theme::DiffStyle; @@ -455,7 +455,6 @@ impl EditorElement { let bounds = gutter_bounds.union_rect(text_bounds); let scroll_top = layout.position_map.snapshot.scroll_position().y() * layout.position_map.line_height; - let editor = self.view(cx.app); cx.scene.push_quad(Quad { bounds: gutter_bounds, background: Some(self.style.gutter_background), @@ -469,7 +468,7 @@ impl EditorElement { corner_radius: 0., }); - if let EditorMode::Full = editor.mode { + if let EditorMode::Full = layout.mode { let mut active_rows = layout.active_rows.iter().peekable(); while let Some((start_row, contains_non_empty_selection)) = active_rows.next() { let mut end_row = *start_row; @@ -753,7 +752,7 @@ impl EditorElement { .snapshot .chars_at(cursor_position) .next() - .and_then(|character| { + .and_then(|(character, _)| { let font_id = cursor_row_layout.font_for_index(cursor_column)?; let text = character.to_string(); @@ -910,6 +909,119 @@ impl EditorElement { cx.scene.pop_layer(); } + fn paint_scrollbar(&mut self, bounds: RectF, layout: &mut LayoutState, cx: &mut PaintContext) { + enum ScrollbarMouseHandlers {} + if layout.mode != EditorMode::Full { + return; + } + + let view = self.view.clone(); + let style = &self.style.theme.scrollbar; + + let top = bounds.min_y(); + let bottom = bounds.max_y(); + let right = bounds.max_x(); + let left = right - style.width; + let row_range = &layout.scrollbar_row_range; + let max_row = layout.max_row as f32 + (row_range.end - row_range.start); + + let mut height = bounds.height(); + let mut first_row_y_offset = 0.0; + + // Impose a minimum height on the scrollbar thumb + let min_thumb_height = + style.min_height_factor * cx.font_cache.line_height(self.style.text.font_size); + let thumb_height = (row_range.end - row_range.start) * height / max_row; + if thumb_height < min_thumb_height { + first_row_y_offset = (min_thumb_height - thumb_height) / 2.0; + height -= min_thumb_height - thumb_height; + } + + let y_for_row = |row: f32| -> f32 { top + first_row_y_offset + row * height / max_row }; + + let thumb_top = y_for_row(row_range.start) - first_row_y_offset; + let thumb_bottom = y_for_row(row_range.end) + first_row_y_offset; + let track_bounds = RectF::from_points(vec2f(left, top), vec2f(right, bottom)); + let thumb_bounds = RectF::from_points(vec2f(left, thumb_top), vec2f(right, thumb_bottom)); + + if layout.show_scrollbars { + cx.scene.push_quad(Quad { + bounds: track_bounds, + border: style.track.border, + background: style.track.background_color, + ..Default::default() + }); + cx.scene.push_quad(Quad { + bounds: thumb_bounds, + border: style.thumb.border, + background: style.thumb.background_color, + corner_radius: style.thumb.corner_radius, + }); + } + + cx.scene.push_cursor_region(CursorRegion { + bounds: track_bounds, + style: CursorStyle::Arrow, + }); + cx.scene.push_mouse_region( + MouseRegion::new::(view.id(), view.id(), track_bounds) + .on_move({ + let view = view.clone(); + move |_, cx| { + if let Some(view) = view.upgrade(cx.deref_mut()) { + view.update(cx.deref_mut(), |view, cx| { + view.make_scrollbar_visible(cx); + }); + } + } + }) + .on_down(MouseButton::Left, { + let view = view.clone(); + let row_range = row_range.clone(); + move |e, cx| { + let y = e.position.y(); + if let Some(view) = view.upgrade(cx.deref_mut()) { + view.update(cx.deref_mut(), |view, cx| { + if y < thumb_top || thumb_bottom < y { + let center_row = + ((y - top) * max_row as f32 / height).round() as u32; + let top_row = center_row.saturating_sub( + (row_range.end - row_range.start) as u32 / 2, + ); + let mut position = view.scroll_position(cx); + position.set_y(top_row as f32); + view.set_scroll_position(position, cx); + } else { + view.make_scrollbar_visible(cx); + } + }); + } + } + }) + .on_drag(MouseButton::Left, { + let view = view.clone(); + move |e, cx| { + let y = e.prev_mouse_position.y(); + let new_y = e.position.y(); + if thumb_top < y && y < thumb_bottom { + if let Some(view) = view.upgrade(cx.deref_mut()) { + view.update(cx.deref_mut(), |view, cx| { + let mut position = view.scroll_position(cx); + position.set_y( + position.y() + (new_y - y) * (max_row as f32) / height, + ); + if position.y() < 0.0 { + position.set_y(0.); + } + view.set_scroll_position(position, cx); + }); + } + } + } + }), + ); + } + #[allow(clippy::too_many_arguments)] fn paint_highlighted_range( &self, @@ -1470,13 +1582,11 @@ impl Element for EditorElement { // The scroll position is a fractional point, the whole number of which represents // the top of the window in terms of display rows. let start_row = scroll_position.y() as u32; - let scroll_top = scroll_position.y() * line_height; + let visible_row_count = (size.y() / line_height).ceil() as u32; + let max_row = snapshot.max_point().row(); // Add 1 to ensure selections bleed off screen - let end_row = 1 + cmp::min( - ((scroll_top + size.y()) / line_height).ceil() as u32, - snapshot.max_point().row(), - ); + let end_row = 1 + cmp::min(start_row + visible_row_count, max_row); let start_anchor = if start_row == 0 { Anchor::min() @@ -1485,7 +1595,7 @@ impl Element for EditorElement { .buffer_snapshot .anchor_before(DisplayPoint::new(start_row, 0).to_offset(&snapshot, Bias::Left)) }; - let end_anchor = if end_row > snapshot.max_point().row() { + let end_anchor = if end_row > max_row { Anchor::max() } else { snapshot @@ -1497,6 +1607,7 @@ impl Element for EditorElement { let mut active_rows = BTreeMap::new(); let mut highlighted_rows = None; let mut highlighted_ranges = Vec::new(); + let mut show_scrollbars = false; self.update_view(cx.app, |view, cx| { let display_map = view.display_map.update(cx, |map, cx| map.snapshot(cx)); @@ -1557,6 +1668,8 @@ impl Element for EditorElement { .collect(), )); } + + show_scrollbars = view.show_scrollbars(); }); let line_number_layouts = @@ -1567,6 +1680,9 @@ impl Element for EditorElement { .git_diff_hunks_in_range(start_row..end_row) .collect(); + let scrollbar_row_range = + scroll_position.y()..(scroll_position.y() + visible_row_count as f32); + let mut max_visible_line_width = 0.0; let line_layouts = self.layout_lines(start_row..end_row, &snapshot, cx); for line in &line_layouts { @@ -1600,10 +1716,9 @@ impl Element for EditorElement { cx, ); - let max_row = snapshot.max_point().row(); let scroll_max = vec2f( ((scroll_width - text_size.x()) / em_width).max(0.0), - max_row.saturating_sub(1) as f32, + max_row as f32, ); self.update_view(cx.app, |view, cx| { @@ -1630,6 +1745,7 @@ impl Element for EditorElement { let mut context_menu = None; let mut code_actions_indicator = None; let mut hover = None; + let mut mode = EditorMode::Full; cx.render(&self.view.upgrade(cx).unwrap(), |view, cx| { let newest_selection_head = view .selections @@ -1651,6 +1767,7 @@ impl Element for EditorElement { let visible_rows = start_row..start_row + line_layouts.len() as u32; hover = view.hover_state.render(&snapshot, &style, visible_rows, cx); + mode = view.mode; }); if let Some((_, context_menu)) = context_menu.as_mut() { @@ -1698,6 +1815,7 @@ impl Element for EditorElement { ( size, LayoutState { + mode, position_map: Arc::new(PositionMap { size, scroll_max, @@ -1710,6 +1828,9 @@ impl Element for EditorElement { gutter_size, gutter_padding, text_size, + scrollbar_row_range, + show_scrollbars, + max_row, gutter_margin, active_rows, highlighted_rows, @@ -1757,11 +1878,12 @@ impl Element for EditorElement { } self.paint_text(text_bounds, visible_bounds, layout, cx); + cx.scene.push_layer(Some(bounds)); if !layout.blocks.is_empty() { - cx.scene.push_layer(Some(bounds)); self.paint_blocks(bounds, visible_bounds, layout, cx); - cx.scene.pop_layer(); } + self.paint_scrollbar(bounds, layout, cx); + cx.scene.pop_layer(); cx.scene.pop_layer(); } @@ -1847,12 +1969,16 @@ pub struct LayoutState { gutter_padding: f32, gutter_margin: f32, text_size: Vector2F, + mode: EditorMode, active_rows: BTreeMap, highlighted_rows: Option>, line_number_layouts: Vec>, blocks: Vec, highlighted_ranges: Vec<(Range, Color)>, selections: Vec<(ReplicaId, Vec)>, + scrollbar_row_range: Range, + show_scrollbars: bool, + max_row: u32, context_menu: Option<(DisplayPoint, ElementBox)>, diff_hunks: Vec>, code_actions_indicator: Option<(u32, ElementBox)>, diff --git a/crates/editor/src/highlight_matching_bracket.rs b/crates/editor/src/highlight_matching_bracket.rs index 789393d70b..043b21db21 100644 --- a/crates/editor/src/highlight_matching_bracket.rs +++ b/crates/editor/src/highlight_matching_bracket.rs @@ -32,8 +32,9 @@ pub fn refresh_matching_bracket_highlights(editor: &mut Editor, cx: &mut ViewCon #[cfg(test)] mod tests { + use crate::test::editor_lsp_test_context::EditorLspTestContext; + use super::*; - use crate::test::EditorLspTestContext; use indoc::indoc; use language::{BracketPair, Language, LanguageConfig}; diff --git a/crates/editor/src/hover_popover.rs b/crates/editor/src/hover_popover.rs index 250f8427a5..38b28f0630 100644 --- a/crates/editor/src/hover_popover.rs +++ b/crates/editor/src/hover_popover.rs @@ -427,13 +427,13 @@ impl DiagnosticPopover { #[cfg(test)] mod tests { - use futures::StreamExt; use indoc::indoc; use language::{Diagnostic, DiagnosticSet}; use project::HoverBlock; + use smol::stream::StreamExt; - use crate::test::EditorLspTestContext; + use crate::test::editor_lsp_test_context::EditorLspTestContext; use super::*; diff --git a/crates/editor/src/link_go_to_definition.rs b/crates/editor/src/link_go_to_definition.rs index 6b23a04b67..c8294ddb43 100644 --- a/crates/editor/src/link_go_to_definition.rs +++ b/crates/editor/src/link_go_to_definition.rs @@ -400,7 +400,7 @@ mod tests { use indoc::indoc; use lsp::request::{GotoDefinition, GotoTypeDefinition}; - use crate::test::EditorLspTestContext; + use crate::test::editor_lsp_test_context::EditorLspTestContext; use super::*; diff --git a/crates/editor/src/mouse_context_menu.rs b/crates/editor/src/mouse_context_menu.rs index 4adc030d99..d9840fd3fa 100644 --- a/crates/editor/src/mouse_context_menu.rs +++ b/crates/editor/src/mouse_context_menu.rs @@ -70,8 +70,9 @@ pub fn deploy_context_menu( #[cfg(test)] mod tests { + use crate::test::editor_lsp_test_context::EditorLspTestContext; + use super::*; - use crate::test::EditorLspTestContext; use indoc::indoc; #[gpui::test] diff --git a/crates/editor/src/movement.rs b/crates/editor/src/movement.rs index 79d041fbab..e5dcf94841 100644 --- a/crates/editor/src/movement.rs +++ b/crates/editor/src/movement.rs @@ -102,6 +102,22 @@ pub fn line_beginning( map: &DisplaySnapshot, display_point: DisplayPoint, stop_at_soft_boundaries: bool, +) -> DisplayPoint { + let point = display_point.to_point(map); + let soft_line_start = map.clip_point(DisplayPoint::new(display_point.row(), 0), Bias::Right); + let line_start = map.prev_line_boundary(point).1; + + if stop_at_soft_boundaries && display_point != soft_line_start { + soft_line_start + } else { + line_start + } +} + +pub fn indented_line_beginning( + map: &DisplaySnapshot, + display_point: DisplayPoint, + stop_at_soft_boundaries: bool, ) -> DisplayPoint { let point = display_point.to_point(map); let soft_line_start = map.clip_point(DisplayPoint::new(display_point.row(), 0), Bias::Right); @@ -168,54 +184,79 @@ pub fn next_subword_end(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPo }) } -/// Scans for a boundary from the start of each line preceding the given end point until a boundary -/// is found, indicated by the given predicate returning true. The predicate is called with the -/// character to the left and right of the candidate boundary location, and will be called with `\n` -/// characters indicating the start or end of a line. If the predicate returns true multiple times -/// on a line, the *rightmost* boundary is returned. +/// Scans for a boundary preceding the given start point `from` until a boundary is found, indicated by the +/// given predicate returning true. The predicate is called with the character to the left and right +/// of the candidate boundary location, and will be called with `\n` characters indicating the start +/// or end of a line. pub fn find_preceding_boundary( map: &DisplaySnapshot, - end: DisplayPoint, + from: DisplayPoint, mut is_boundary: impl FnMut(char, char) -> bool, ) -> DisplayPoint { - let mut point = end; - loop { - *point.column_mut() = 0; - if point.row() > 0 { - if let Some(indent) = map.soft_wrap_indent(point.row() - 1) { - *point.column_mut() = indent; + let mut start_column = 0; + let mut soft_wrap_row = from.row() + 1; + + let mut prev = None; + for (ch, point) in map.reverse_chars_at(from) { + // Recompute soft_wrap_indent if the row has changed + if point.row() != soft_wrap_row { + soft_wrap_row = point.row(); + + if point.row() == 0 { + start_column = 0; + } else if let Some(indent) = map.soft_wrap_indent(point.row() - 1) { + start_column = indent; } } - let mut boundary = None; - let mut prev_ch = if point.is_zero() { None } else { Some('\n') }; - for ch in map.chars_at(point) { - if point >= end { - break; - } - - if let Some(prev_ch) = prev_ch { - if is_boundary(prev_ch, ch) { - boundary = Some(point); - } - } - - if ch == '\n' { - break; - } - - prev_ch = Some(ch); - *point.column_mut() += ch.len_utf8() as u32; + // If the current point is in the soft_wrap, skip comparing it + if point.column() < start_column { + continue; } - if let Some(boundary) = boundary { - return boundary; - } else if point.row() == 0 { - return DisplayPoint::zero(); - } else { - *point.row_mut() -= 1; + if let Some((prev_ch, prev_point)) = prev { + if is_boundary(ch, prev_ch) { + return prev_point; + } + } + + prev = Some((ch, point)); + } + DisplayPoint::zero() +} + +/// Scans for a boundary preceding the given start point `from` until a boundary is found, indicated by the +/// given predicate returning true. The predicate is called with the character to the left and right +/// of the candidate boundary location, and will be called with `\n` characters indicating the start +/// or end of a line. If no boundary is found, the start of the line is returned. +pub fn find_preceding_boundary_in_line( + map: &DisplaySnapshot, + from: DisplayPoint, + mut is_boundary: impl FnMut(char, char) -> bool, +) -> DisplayPoint { + let mut start_column = 0; + if from.row() > 0 { + if let Some(indent) = map.soft_wrap_indent(from.row() - 1) { + start_column = indent; } } + + let mut prev = None; + for (ch, point) in map.reverse_chars_at(from) { + if let Some((prev_ch, prev_point)) = prev { + if is_boundary(ch, prev_ch) { + return prev_point; + } + } + + if ch == '\n' || point.column() < start_column { + break; + } + + prev = Some((ch, point)); + } + + prev.map(|(_, point)| point).unwrap_or(from) } /// Scans for a boundary following the given start point until a boundary is found, indicated by the @@ -224,26 +265,48 @@ pub fn find_preceding_boundary( /// or end of a line. pub fn find_boundary( map: &DisplaySnapshot, - mut point: DisplayPoint, + from: DisplayPoint, mut is_boundary: impl FnMut(char, char) -> bool, ) -> DisplayPoint { let mut prev_ch = None; - for ch in map.chars_at(point) { + for (ch, point) in map.chars_at(from) { if let Some(prev_ch) = prev_ch { if is_boundary(prev_ch, ch) { - break; + return map.clip_point(point, Bias::Right); } } - if ch == '\n' { - *point.row_mut() += 1; - *point.column_mut() = 0; - } else { - *point.column_mut() += ch.len_utf8() as u32; - } prev_ch = Some(ch); } - map.clip_point(point, Bias::Right) + map.clip_point(map.max_point(), Bias::Right) +} + +/// Scans for a boundary following the given start point until a boundary is found, indicated by the +/// given predicate returning true. The predicate is called with the character to the left and right +/// of the candidate boundary location, and will be called with `\n` characters indicating the start +/// or end of a line. If no boundary is found, the end of the line is returned +pub fn find_boundary_in_line( + map: &DisplaySnapshot, + from: DisplayPoint, + mut is_boundary: impl FnMut(char, char) -> bool, +) -> DisplayPoint { + let mut prev = None; + for (ch, point) in map.chars_at(from) { + if let Some((prev_ch, _)) = prev { + if is_boundary(prev_ch, ch) { + return map.clip_point(point, Bias::Right); + } + } + + prev = Some((ch, point)); + + if ch == '\n' { + break; + } + } + + // Return the last position checked so that we give a point right before the newline or eof. + map.clip_point(prev.map(|(_, point)| point).unwrap_or(from), Bias::Right) } pub fn is_inside_word(map: &DisplaySnapshot, point: DisplayPoint) -> bool { diff --git a/crates/editor/src/test.rs b/crates/editor/src/test.rs index 75bc5fe76a..48652c44b7 100644 --- a/crates/editor/src/test.rs +++ b/crates/editor/src/test.rs @@ -1,28 +1,14 @@ +pub mod editor_lsp_test_context; +pub mod editor_test_context; + use crate::{ display_map::{DisplayMap, DisplaySnapshot, ToDisplayPoint}, - multi_buffer::ToPointUtf16, - AnchorRangeExt, Autoscroll, DisplayPoint, Editor, EditorMode, MultiBuffer, ToPoint, + DisplayPoint, Editor, EditorMode, MultiBuffer, }; -use anyhow::Result; -use futures::{Future, StreamExt}; -use gpui::{ - json, keymap::Keystroke, AppContext, ModelContext, ModelHandle, ViewContext, ViewHandle, -}; -use indoc::indoc; -use language::{point_to_lsp, Buffer, BufferSnapshot, FakeLspAdapter, Language, LanguageConfig}; -use lsp::{notification, request}; -use project::Project; -use settings::Settings; -use std::{ - any::TypeId, - ops::{Deref, DerefMut, Range}, - sync::Arc, -}; -use util::{ - assert_set_eq, set_eq, - test::{generate_marked_text, marked_text_offsets, marked_text_ranges}, -}; -use workspace::{pane, AppState, Workspace, WorkspaceHandle}; + +use gpui::{ModelHandle, ViewContext}; + +use util::test::{marked_text_offsets, marked_text_ranges}; #[cfg(test)] #[ctor::ctor] @@ -80,430 +66,3 @@ pub(crate) fn build_editor( ) -> Editor { Editor::new(EditorMode::Full, buffer, None, None, cx) } - -pub struct EditorTestContext<'a> { - pub cx: &'a mut gpui::TestAppContext, - pub window_id: usize, - pub editor: ViewHandle, -} - -impl<'a> EditorTestContext<'a> { - pub fn new(cx: &'a mut gpui::TestAppContext) -> EditorTestContext<'a> { - let (window_id, editor) = cx.update(|cx| { - cx.set_global(Settings::test(cx)); - crate::init(cx); - - let (window_id, editor) = cx.add_window(Default::default(), |cx| { - build_editor(MultiBuffer::build_simple("", cx), cx) - }); - - editor.update(cx, |_, cx| cx.focus_self()); - - (window_id, editor) - }); - - Self { - cx, - window_id, - editor, - } - } - - pub fn condition( - &self, - predicate: impl FnMut(&Editor, &AppContext) -> bool, - ) -> impl Future { - self.editor.condition(self.cx, predicate) - } - - pub fn editor(&self, read: F) -> T - where - F: FnOnce(&Editor, &AppContext) -> T, - { - self.editor.read_with(self.cx, read) - } - - pub fn update_editor(&mut self, update: F) -> T - where - F: FnOnce(&mut Editor, &mut ViewContext) -> T, - { - self.editor.update(self.cx, update) - } - - pub fn multibuffer(&self, read: F) -> T - where - F: FnOnce(&MultiBuffer, &AppContext) -> T, - { - self.editor(|editor, cx| read(editor.buffer().read(cx), cx)) - } - - pub fn update_multibuffer(&mut self, update: F) -> T - where - F: FnOnce(&mut MultiBuffer, &mut ModelContext) -> T, - { - self.update_editor(|editor, cx| editor.buffer().update(cx, update)) - } - - pub fn buffer_text(&self) -> String { - self.multibuffer(|buffer, cx| buffer.snapshot(cx).text()) - } - - pub fn buffer(&self, read: F) -> T - where - F: FnOnce(&Buffer, &AppContext) -> T, - { - self.multibuffer(|multibuffer, cx| { - let buffer = multibuffer.as_singleton().unwrap().read(cx); - read(buffer, cx) - }) - } - - pub fn update_buffer(&mut self, update: F) -> T - where - F: FnOnce(&mut Buffer, &mut ModelContext) -> T, - { - self.update_multibuffer(|multibuffer, cx| { - let buffer = multibuffer.as_singleton().unwrap(); - buffer.update(cx, update) - }) - } - - pub fn buffer_snapshot(&self) -> BufferSnapshot { - self.buffer(|buffer, _| buffer.snapshot()) - } - - pub fn simulate_keystroke(&mut self, keystroke_text: &str) { - let keystroke = Keystroke::parse(keystroke_text).unwrap(); - self.cx.dispatch_keystroke(self.window_id, keystroke, false); - } - - pub fn simulate_keystrokes(&mut self, keystroke_texts: [&str; COUNT]) { - for keystroke_text in keystroke_texts.into_iter() { - self.simulate_keystroke(keystroke_text); - } - } - - pub fn ranges(&self, marked_text: &str) -> Vec> { - let (unmarked_text, ranges) = marked_text_ranges(marked_text, false); - assert_eq!(self.buffer_text(), unmarked_text); - ranges - } - - pub fn display_point(&mut self, marked_text: &str) -> DisplayPoint { - let ranges = self.ranges(marked_text); - let snapshot = self - .editor - .update(self.cx, |editor, cx| editor.snapshot(cx)); - ranges[0].start.to_display_point(&snapshot) - } - - // Returns anchors for the current buffer using `«` and `»` - pub fn text_anchor_range(&self, marked_text: &str) -> Range { - let ranges = self.ranges(marked_text); - let snapshot = self.buffer_snapshot(); - snapshot.anchor_before(ranges[0].start)..snapshot.anchor_after(ranges[0].end) - } - - /// Change the editor's text and selections using a string containing - /// embedded range markers that represent the ranges and directions of - /// each selection. - /// - /// See the `util::test::marked_text_ranges` function for more information. - pub fn set_state(&mut self, marked_text: &str) { - let (unmarked_text, selection_ranges) = marked_text_ranges(marked_text, true); - self.editor.update(self.cx, |editor, cx| { - editor.set_text(unmarked_text, cx); - editor.change_selections(Some(Autoscroll::Fit), cx, |s| { - s.select_ranges(selection_ranges) - }) - }) - } - - /// Make an assertion about the editor's text and the ranges and directions - /// of its selections using a string containing embedded range markers. - /// - /// See the `util::test::marked_text_ranges` function for more information. - pub fn assert_editor_state(&mut self, marked_text: &str) { - let (unmarked_text, expected_selections) = marked_text_ranges(marked_text, true); - let buffer_text = self.buffer_text(); - assert_eq!( - buffer_text, unmarked_text, - "Unmarked text doesn't match buffer text" - ); - self.assert_selections(expected_selections, marked_text.to_string()) - } - - pub fn assert_editor_background_highlights(&mut self, marked_text: &str) { - let expected_ranges = self.ranges(marked_text); - let actual_ranges: Vec> = self.update_editor(|editor, cx| { - let snapshot = editor.snapshot(cx); - editor - .background_highlights - .get(&TypeId::of::()) - .map(|h| h.1.clone()) - .unwrap_or_default() - .into_iter() - .map(|range| range.to_offset(&snapshot.buffer_snapshot)) - .collect() - }); - assert_set_eq!(actual_ranges, expected_ranges); - } - - pub fn assert_editor_text_highlights(&mut self, marked_text: &str) { - let expected_ranges = self.ranges(marked_text); - let snapshot = self.update_editor(|editor, cx| editor.snapshot(cx)); - let actual_ranges: Vec> = snapshot - .highlight_ranges::() - .map(|ranges| ranges.as_ref().clone().1) - .unwrap_or_default() - .into_iter() - .map(|range| range.to_offset(&snapshot.buffer_snapshot)) - .collect(); - assert_set_eq!(actual_ranges, expected_ranges); - } - - pub fn assert_editor_selections(&mut self, expected_selections: Vec>) { - let expected_marked_text = - generate_marked_text(&self.buffer_text(), &expected_selections, true); - self.assert_selections(expected_selections, expected_marked_text) - } - - fn assert_selections( - &mut self, - expected_selections: Vec>, - expected_marked_text: String, - ) { - let actual_selections = self - .editor - .read_with(self.cx, |editor, cx| editor.selections.all::(cx)) - .into_iter() - .map(|s| { - if s.reversed { - s.end..s.start - } else { - s.start..s.end - } - }) - .collect::>(); - let actual_marked_text = - generate_marked_text(&self.buffer_text(), &actual_selections, true); - if expected_selections != actual_selections { - panic!( - indoc! {" - Editor has unexpected selections. - - Expected selections: - {} - - Actual selections: - {} - "}, - expected_marked_text, actual_marked_text, - ); - } - } -} - -impl<'a> Deref for EditorTestContext<'a> { - type Target = gpui::TestAppContext; - - fn deref(&self) -> &Self::Target { - self.cx - } -} - -impl<'a> DerefMut for EditorTestContext<'a> { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.cx - } -} - -pub struct EditorLspTestContext<'a> { - pub cx: EditorTestContext<'a>, - pub lsp: lsp::FakeLanguageServer, - pub workspace: ViewHandle, - pub buffer_lsp_url: lsp::Url, -} - -impl<'a> EditorLspTestContext<'a> { - pub async fn new( - mut language: Language, - capabilities: lsp::ServerCapabilities, - cx: &'a mut gpui::TestAppContext, - ) -> EditorLspTestContext<'a> { - use json::json; - - cx.update(|cx| { - crate::init(cx); - pane::init(cx); - }); - - let params = cx.update(AppState::test); - - let file_name = format!( - "file.{}", - language - .path_suffixes() - .first() - .unwrap_or(&"txt".to_string()) - ); - - let mut fake_servers = language - .set_fake_lsp_adapter(Arc::new(FakeLspAdapter { - capabilities, - ..Default::default() - })) - .await; - - let project = Project::test(params.fs.clone(), [], cx).await; - project.update(cx, |project, _| project.languages().add(Arc::new(language))); - - params - .fs - .as_fake() - .insert_tree("/root", json!({ "dir": { file_name: "" }})) - .await; - - let (window_id, workspace) = - cx.add_window(|cx| Workspace::new(project.clone(), |_, _| unimplemented!(), cx)); - project - .update(cx, |project, cx| { - project.find_or_create_local_worktree("/root", true, cx) - }) - .await - .unwrap(); - cx.read(|cx| workspace.read(cx).worktree_scans_complete(cx)) - .await; - - let file = cx.read(|cx| workspace.file_project_paths(cx)[0].clone()); - let item = workspace - .update(cx, |workspace, cx| workspace.open_path(file, true, cx)) - .await - .expect("Could not open test file"); - - let editor = cx.update(|cx| { - item.act_as::(cx) - .expect("Opened test file wasn't an editor") - }); - editor.update(cx, |_, cx| cx.focus_self()); - - let lsp = fake_servers.next().await.unwrap(); - - Self { - cx: EditorTestContext { - cx, - window_id, - editor, - }, - lsp, - workspace, - buffer_lsp_url: lsp::Url::from_file_path("/root/dir/file.rs").unwrap(), - } - } - - pub async fn new_rust( - capabilities: lsp::ServerCapabilities, - cx: &'a mut gpui::TestAppContext, - ) -> EditorLspTestContext<'a> { - let language = Language::new( - LanguageConfig { - name: "Rust".into(), - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - Some(tree_sitter_rust::language()), - ); - - Self::new(language, capabilities, cx).await - } - - // Constructs lsp range using a marked string with '[', ']' range delimiters - pub fn lsp_range(&mut self, marked_text: &str) -> lsp::Range { - let ranges = self.ranges(marked_text); - self.to_lsp_range(ranges[0].clone()) - } - - pub fn to_lsp_range(&mut self, range: Range) -> lsp::Range { - let snapshot = self.update_editor(|editor, cx| editor.snapshot(cx)); - let start_point = range.start.to_point(&snapshot.buffer_snapshot); - let end_point = range.end.to_point(&snapshot.buffer_snapshot); - - self.editor(|editor, cx| { - let buffer = editor.buffer().read(cx); - let start = point_to_lsp( - buffer - .point_to_buffer_offset(start_point, cx) - .unwrap() - .1 - .to_point_utf16(&buffer.read(cx)), - ); - let end = point_to_lsp( - buffer - .point_to_buffer_offset(end_point, cx) - .unwrap() - .1 - .to_point_utf16(&buffer.read(cx)), - ); - - lsp::Range { start, end } - }) - } - - pub fn to_lsp(&mut self, offset: usize) -> lsp::Position { - let snapshot = self.update_editor(|editor, cx| editor.snapshot(cx)); - let point = offset.to_point(&snapshot.buffer_snapshot); - - self.editor(|editor, cx| { - let buffer = editor.buffer().read(cx); - point_to_lsp( - buffer - .point_to_buffer_offset(point, cx) - .unwrap() - .1 - .to_point_utf16(&buffer.read(cx)), - ) - }) - } - - pub fn update_workspace(&mut self, update: F) -> T - where - F: FnOnce(&mut Workspace, &mut ViewContext) -> T, - { - self.workspace.update(self.cx.cx, update) - } - - pub fn handle_request( - &self, - mut handler: F, - ) -> futures::channel::mpsc::UnboundedReceiver<()> - where - T: 'static + request::Request, - T::Params: 'static + Send, - F: 'static + Send + FnMut(lsp::Url, T::Params, gpui::AsyncAppContext) -> Fut, - Fut: 'static + Send + Future>, - { - let url = self.buffer_lsp_url.clone(); - self.lsp.handle_request::(move |params, cx| { - let url = url.clone(); - handler(url, params, cx) - }) - } - - pub fn notify(&self, params: T::Params) { - self.lsp.notify::(params); - } -} - -impl<'a> Deref for EditorLspTestContext<'a> { - type Target = EditorTestContext<'a>; - - fn deref(&self) -> &Self::Target { - &self.cx - } -} - -impl<'a> DerefMut for EditorLspTestContext<'a> { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.cx - } -} diff --git a/crates/editor/src/test/editor_lsp_test_context.rs b/crates/editor/src/test/editor_lsp_test_context.rs new file mode 100644 index 0000000000..b4a4cd7ab8 --- /dev/null +++ b/crates/editor/src/test/editor_lsp_test_context.rs @@ -0,0 +1,208 @@ +use std::{ + ops::{Deref, DerefMut, Range}, + sync::Arc, +}; + +use anyhow::Result; + +use futures::Future; +use gpui::{json, ViewContext, ViewHandle}; +use language::{point_to_lsp, FakeLspAdapter, Language, LanguageConfig}; +use lsp::{notification, request}; +use project::Project; +use smol::stream::StreamExt; +use workspace::{pane, AppState, Workspace, WorkspaceHandle}; + +use crate::{multi_buffer::ToPointUtf16, Editor, ToPoint}; + +use super::editor_test_context::EditorTestContext; + +pub struct EditorLspTestContext<'a> { + pub cx: EditorTestContext<'a>, + pub lsp: lsp::FakeLanguageServer, + pub workspace: ViewHandle, + pub buffer_lsp_url: lsp::Url, +} + +impl<'a> EditorLspTestContext<'a> { + pub async fn new( + mut language: Language, + capabilities: lsp::ServerCapabilities, + cx: &'a mut gpui::TestAppContext, + ) -> EditorLspTestContext<'a> { + use json::json; + + cx.update(|cx| { + crate::init(cx); + pane::init(cx); + }); + + let params = cx.update(AppState::test); + + let file_name = format!( + "file.{}", + language + .path_suffixes() + .first() + .unwrap_or(&"txt".to_string()) + ); + + let mut fake_servers = language + .set_fake_lsp_adapter(Arc::new(FakeLspAdapter { + capabilities, + ..Default::default() + })) + .await; + + let project = Project::test(params.fs.clone(), [], cx).await; + project.update(cx, |project, _| project.languages().add(Arc::new(language))); + + params + .fs + .as_fake() + .insert_tree("/root", json!({ "dir": { file_name: "" }})) + .await; + + let (window_id, workspace) = + cx.add_window(|cx| Workspace::new(project.clone(), |_, _| unimplemented!(), cx)); + project + .update(cx, |project, cx| { + project.find_or_create_local_worktree("/root", true, cx) + }) + .await + .unwrap(); + cx.read(|cx| workspace.read(cx).worktree_scans_complete(cx)) + .await; + + let file = cx.read(|cx| workspace.file_project_paths(cx)[0].clone()); + let item = workspace + .update(cx, |workspace, cx| workspace.open_path(file, true, cx)) + .await + .expect("Could not open test file"); + + let editor = cx.update(|cx| { + item.act_as::(cx) + .expect("Opened test file wasn't an editor") + }); + editor.update(cx, |_, cx| cx.focus_self()); + + let lsp = fake_servers.next().await.unwrap(); + + Self { + cx: EditorTestContext { + cx, + window_id, + editor, + }, + lsp, + workspace, + buffer_lsp_url: lsp::Url::from_file_path("/root/dir/file.rs").unwrap(), + } + } + + pub async fn new_rust( + capabilities: lsp::ServerCapabilities, + cx: &'a mut gpui::TestAppContext, + ) -> EditorLspTestContext<'a> { + let language = Language::new( + LanguageConfig { + name: "Rust".into(), + path_suffixes: vec!["rs".to_string()], + ..Default::default() + }, + Some(tree_sitter_rust::language()), + ); + + Self::new(language, capabilities, cx).await + } + + // Constructs lsp range using a marked string with '[', ']' range delimiters + pub fn lsp_range(&mut self, marked_text: &str) -> lsp::Range { + let ranges = self.ranges(marked_text); + self.to_lsp_range(ranges[0].clone()) + } + + pub fn to_lsp_range(&mut self, range: Range) -> lsp::Range { + let snapshot = self.update_editor(|editor, cx| editor.snapshot(cx)); + let start_point = range.start.to_point(&snapshot.buffer_snapshot); + let end_point = range.end.to_point(&snapshot.buffer_snapshot); + + self.editor(|editor, cx| { + let buffer = editor.buffer().read(cx); + let start = point_to_lsp( + buffer + .point_to_buffer_offset(start_point, cx) + .unwrap() + .1 + .to_point_utf16(&buffer.read(cx)), + ); + let end = point_to_lsp( + buffer + .point_to_buffer_offset(end_point, cx) + .unwrap() + .1 + .to_point_utf16(&buffer.read(cx)), + ); + + lsp::Range { start, end } + }) + } + + pub fn to_lsp(&mut self, offset: usize) -> lsp::Position { + let snapshot = self.update_editor(|editor, cx| editor.snapshot(cx)); + let point = offset.to_point(&snapshot.buffer_snapshot); + + self.editor(|editor, cx| { + let buffer = editor.buffer().read(cx); + point_to_lsp( + buffer + .point_to_buffer_offset(point, cx) + .unwrap() + .1 + .to_point_utf16(&buffer.read(cx)), + ) + }) + } + + pub fn update_workspace(&mut self, update: F) -> T + where + F: FnOnce(&mut Workspace, &mut ViewContext) -> T, + { + self.workspace.update(self.cx.cx, update) + } + + pub fn handle_request( + &self, + mut handler: F, + ) -> futures::channel::mpsc::UnboundedReceiver<()> + where + T: 'static + request::Request, + T::Params: 'static + Send, + F: 'static + Send + FnMut(lsp::Url, T::Params, gpui::AsyncAppContext) -> Fut, + Fut: 'static + Send + Future>, + { + let url = self.buffer_lsp_url.clone(); + self.lsp.handle_request::(move |params, cx| { + let url = url.clone(); + handler(url, params, cx) + }) + } + + pub fn notify(&self, params: T::Params) { + self.lsp.notify::(params); + } +} + +impl<'a> Deref for EditorLspTestContext<'a> { + type Target = EditorTestContext<'a>; + + fn deref(&self) -> &Self::Target { + &self.cx + } +} + +impl<'a> DerefMut for EditorLspTestContext<'a> { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.cx + } +} diff --git a/crates/editor/src/test/editor_test_context.rs b/crates/editor/src/test/editor_test_context.rs new file mode 100644 index 0000000000..73dc6bfd6e --- /dev/null +++ b/crates/editor/src/test/editor_test_context.rs @@ -0,0 +1,273 @@ +use std::{ + any::TypeId, + ops::{Deref, DerefMut, Range}, +}; + +use futures::Future; +use indoc::indoc; + +use crate::{ + display_map::ToDisplayPoint, AnchorRangeExt, Autoscroll, DisplayPoint, Editor, MultiBuffer, +}; +use gpui::{keymap::Keystroke, AppContext, ContextHandle, ModelContext, ViewContext, ViewHandle}; +use language::{Buffer, BufferSnapshot}; +use settings::Settings; +use util::{ + assert_set_eq, + test::{generate_marked_text, marked_text_ranges}, +}; + +use super::build_editor; + +pub struct EditorTestContext<'a> { + pub cx: &'a mut gpui::TestAppContext, + pub window_id: usize, + pub editor: ViewHandle, +} + +impl<'a> EditorTestContext<'a> { + pub fn new(cx: &'a mut gpui::TestAppContext) -> EditorTestContext<'a> { + let (window_id, editor) = cx.update(|cx| { + cx.set_global(Settings::test(cx)); + crate::init(cx); + + let (window_id, editor) = cx.add_window(Default::default(), |cx| { + build_editor(MultiBuffer::build_simple("", cx), cx) + }); + + editor.update(cx, |_, cx| cx.focus_self()); + + (window_id, editor) + }); + + Self { + cx, + window_id, + editor, + } + } + + pub fn condition( + &self, + predicate: impl FnMut(&Editor, &AppContext) -> bool, + ) -> impl Future { + self.editor.condition(self.cx, predicate) + } + + pub fn editor(&self, read: F) -> T + where + F: FnOnce(&Editor, &AppContext) -> T, + { + self.editor.read_with(self.cx, read) + } + + pub fn update_editor(&mut self, update: F) -> T + where + F: FnOnce(&mut Editor, &mut ViewContext) -> T, + { + self.editor.update(self.cx, update) + } + + pub fn multibuffer(&self, read: F) -> T + where + F: FnOnce(&MultiBuffer, &AppContext) -> T, + { + self.editor(|editor, cx| read(editor.buffer().read(cx), cx)) + } + + pub fn update_multibuffer(&mut self, update: F) -> T + where + F: FnOnce(&mut MultiBuffer, &mut ModelContext) -> T, + { + self.update_editor(|editor, cx| editor.buffer().update(cx, update)) + } + + pub fn buffer_text(&self) -> String { + self.multibuffer(|buffer, cx| buffer.snapshot(cx).text()) + } + + pub fn buffer(&self, read: F) -> T + where + F: FnOnce(&Buffer, &AppContext) -> T, + { + self.multibuffer(|multibuffer, cx| { + let buffer = multibuffer.as_singleton().unwrap().read(cx); + read(buffer, cx) + }) + } + + pub fn update_buffer(&mut self, update: F) -> T + where + F: FnOnce(&mut Buffer, &mut ModelContext) -> T, + { + self.update_multibuffer(|multibuffer, cx| { + let buffer = multibuffer.as_singleton().unwrap(); + buffer.update(cx, update) + }) + } + + pub fn buffer_snapshot(&self) -> BufferSnapshot { + self.buffer(|buffer, _| buffer.snapshot()) + } + + pub fn simulate_keystroke(&mut self, keystroke_text: &str) -> ContextHandle { + let keystroke_under_test_handle = + self.add_assertion_context(format!("Simulated Keystroke: {:?}", keystroke_text)); + let keystroke = Keystroke::parse(keystroke_text).unwrap(); + self.cx.dispatch_keystroke(self.window_id, keystroke, false); + keystroke_under_test_handle + } + + pub fn simulate_keystrokes( + &mut self, + keystroke_texts: [&str; COUNT], + ) -> ContextHandle { + let keystrokes_under_test_handle = + self.add_assertion_context(format!("Simulated Keystrokes: {:?}", keystroke_texts)); + for keystroke_text in keystroke_texts.into_iter() { + self.simulate_keystroke(keystroke_text); + } + keystrokes_under_test_handle + } + + pub fn ranges(&self, marked_text: &str) -> Vec> { + let (unmarked_text, ranges) = marked_text_ranges(marked_text, false); + assert_eq!(self.buffer_text(), unmarked_text); + ranges + } + + pub fn display_point(&mut self, marked_text: &str) -> DisplayPoint { + let ranges = self.ranges(marked_text); + let snapshot = self + .editor + .update(self.cx, |editor, cx| editor.snapshot(cx)); + ranges[0].start.to_display_point(&snapshot) + } + + // Returns anchors for the current buffer using `«` and `»` + pub fn text_anchor_range(&self, marked_text: &str) -> Range { + let ranges = self.ranges(marked_text); + let snapshot = self.buffer_snapshot(); + snapshot.anchor_before(ranges[0].start)..snapshot.anchor_after(ranges[0].end) + } + + /// Change the editor's text and selections using a string containing + /// embedded range markers that represent the ranges and directions of + /// each selection. + /// + /// See the `util::test::marked_text_ranges` function for more information. + pub fn set_state(&mut self, marked_text: &str) -> ContextHandle { + let _state_context = self.add_assertion_context(format!( + "Editor State: \"{}\"", + marked_text.escape_debug().to_string() + )); + let (unmarked_text, selection_ranges) = marked_text_ranges(marked_text, true); + self.editor.update(self.cx, |editor, cx| { + editor.set_text(unmarked_text, cx); + editor.change_selections(Some(Autoscroll::Fit), cx, |s| { + s.select_ranges(selection_ranges) + }) + }); + _state_context + } + + /// Make an assertion about the editor's text and the ranges and directions + /// of its selections using a string containing embedded range markers. + /// + /// See the `util::test::marked_text_ranges` function for more information. + pub fn assert_editor_state(&mut self, marked_text: &str) { + let (unmarked_text, expected_selections) = marked_text_ranges(marked_text, true); + let buffer_text = self.buffer_text(); + assert_eq!( + buffer_text, unmarked_text, + "Unmarked text doesn't match buffer text" + ); + self.assert_selections(expected_selections, marked_text.to_string()) + } + + pub fn assert_editor_background_highlights(&mut self, marked_text: &str) { + let expected_ranges = self.ranges(marked_text); + let actual_ranges: Vec> = self.update_editor(|editor, cx| { + let snapshot = editor.snapshot(cx); + editor + .background_highlights + .get(&TypeId::of::()) + .map(|h| h.1.clone()) + .unwrap_or_default() + .into_iter() + .map(|range| range.to_offset(&snapshot.buffer_snapshot)) + .collect() + }); + assert_set_eq!(actual_ranges, expected_ranges); + } + + pub fn assert_editor_text_highlights(&mut self, marked_text: &str) { + let expected_ranges = self.ranges(marked_text); + let snapshot = self.update_editor(|editor, cx| editor.snapshot(cx)); + let actual_ranges: Vec> = snapshot + .highlight_ranges::() + .map(|ranges| ranges.as_ref().clone().1) + .unwrap_or_default() + .into_iter() + .map(|range| range.to_offset(&snapshot.buffer_snapshot)) + .collect(); + assert_set_eq!(actual_ranges, expected_ranges); + } + + pub fn assert_editor_selections(&mut self, expected_selections: Vec>) { + let expected_marked_text = + generate_marked_text(&self.buffer_text(), &expected_selections, true); + self.assert_selections(expected_selections, expected_marked_text) + } + + fn assert_selections( + &mut self, + expected_selections: Vec>, + expected_marked_text: String, + ) { + let actual_selections = self + .editor + .read_with(self.cx, |editor, cx| editor.selections.all::(cx)) + .into_iter() + .map(|s| { + if s.reversed { + s.end..s.start + } else { + s.start..s.end + } + }) + .collect::>(); + let actual_marked_text = + generate_marked_text(&self.buffer_text(), &actual_selections, true); + if expected_selections != actual_selections { + panic!( + indoc! {" + {}Editor has unexpected selections. + + Expected selections: + {} + + Actual selections: + {} + "}, + self.assertion_context(), + expected_marked_text, + actual_marked_text, + ); + } + } +} + +impl<'a> Deref for EditorTestContext<'a> { + type Target = gpui::TestAppContext; + + fn deref(&self) -> &Self::Target { + self.cx + } +} + +impl<'a> DerefMut for EditorTestContext<'a> { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.cx + } +} diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index 51bc416e19..54fe5e46a2 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -25,6 +25,7 @@ env_logger = { version = "0.9", optional = true } etagere = "0.2" futures = "0.3" image = "0.23" +itertools = "0.10" lazy_static = "1.4.0" log = { version = "0.4.16", features = ["kv_unstable_serde"] } num_cpus = "1.13" diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index ed351cdefe..cd6b2dca4b 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -1,28 +1,8 @@ pub mod action; mod callback_collection; +#[cfg(any(test, feature = "test-support"))] +pub mod test_app_context; -use crate::{ - elements::ElementBox, - executor::{self, Task}, - geometry::rect::RectF, - keymap::{self, Binding, Keystroke}, - platform::{self, KeyDownEvent, Platform, PromptLevel, WindowOptions}, - presenter::Presenter, - util::post_inc, - Appearance, AssetCache, AssetSource, ClipboardItem, FontCache, InputHandler, MouseButton, - MouseRegionId, PathPromptOptions, TextLayoutCache, -}; -pub use action::*; -use anyhow::{anyhow, Context, Result}; -use callback_collection::CallbackCollection; -use collections::{btree_map, hash_map::Entry, BTreeMap, HashMap, HashSet, VecDeque}; -use keymap::MatchResult; -use lazy_static::lazy_static; -use parking_lot::Mutex; -use platform::Event; -use postage::oneshot; -use smallvec::SmallVec; -use smol::prelude::*; use std::{ any::{type_name, Any, TypeId}, cell::RefCell, @@ -38,7 +18,32 @@ use std::{ time::Duration, }; -use self::callback_collection::Mapping; +use anyhow::{anyhow, Context, Result}; +use lazy_static::lazy_static; +use parking_lot::Mutex; +use postage::oneshot; +use smallvec::SmallVec; +use smol::prelude::*; + +pub use action::*; +use callback_collection::{CallbackCollection, Mapping}; +use collections::{btree_map, hash_map::Entry, BTreeMap, HashMap, HashSet, VecDeque}; +use keymap::MatchResult; +use platform::Event; +#[cfg(any(test, feature = "test-support"))] +pub use test_app_context::{ContextHandle, TestAppContext}; + +use crate::{ + elements::ElementBox, + executor::{self, Task}, + geometry::rect::RectF, + keymap::{self, Binding, Keystroke}, + platform::{self, KeyDownEvent, Platform, PromptLevel, WindowOptions}, + presenter::Presenter, + util::post_inc, + Appearance, AssetCache, AssetSource, ClipboardItem, FontCache, InputHandler, MouseButton, + MouseRegionId, PathPromptOptions, TextLayoutCache, +}; pub trait Entity: 'static { type Event; @@ -177,13 +182,6 @@ pub struct App(Rc>); #[derive(Clone)] pub struct AsyncAppContext(Rc>); -#[cfg(any(test, feature = "test-support"))] -pub struct TestAppContext { - cx: Rc>, - foreground_platform: Rc, - condition_duration: Option, -} - pub struct WindowInputHandler { app: Rc>, window_id: usize, @@ -427,327 +425,6 @@ impl InputHandler for WindowInputHandler { } } -#[cfg(any(test, feature = "test-support"))] -impl TestAppContext { - pub fn new( - foreground_platform: Rc, - platform: Arc, - foreground: Rc, - background: Arc, - font_cache: Arc, - leak_detector: Arc>, - first_entity_id: usize, - ) -> Self { - let mut cx = MutableAppContext::new( - foreground, - background, - platform, - foreground_platform.clone(), - font_cache, - RefCounts { - #[cfg(any(test, feature = "test-support"))] - leak_detector, - ..Default::default() - }, - (), - ); - cx.next_entity_id = first_entity_id; - let cx = TestAppContext { - cx: Rc::new(RefCell::new(cx)), - foreground_platform, - condition_duration: None, - }; - cx.cx.borrow_mut().weak_self = Some(Rc::downgrade(&cx.cx)); - cx - } - - pub fn dispatch_action(&self, window_id: usize, action: A) { - let mut cx = self.cx.borrow_mut(); - if let Some(view_id) = cx.focused_view_id(window_id) { - cx.handle_dispatch_action_from_effect(window_id, Some(view_id), &action); - } - } - - pub fn dispatch_global_action(&self, action: A) { - self.cx.borrow_mut().dispatch_global_action(action); - } - - pub fn dispatch_keystroke(&mut self, window_id: usize, keystroke: Keystroke, is_held: bool) { - let handled = self.cx.borrow_mut().update(|cx| { - let presenter = cx - .presenters_and_platform_windows - .get(&window_id) - .unwrap() - .0 - .clone(); - - if cx.dispatch_keystroke(window_id, &keystroke) { - return true; - } - - if presenter.borrow_mut().dispatch_event( - Event::KeyDown(KeyDownEvent { - keystroke: keystroke.clone(), - is_held, - }), - false, - cx, - ) { - return true; - } - - false - }); - - if !handled && !keystroke.cmd && !keystroke.ctrl { - WindowInputHandler { - app: self.cx.clone(), - window_id, - } - .replace_text_in_range(None, &keystroke.key) - } - } - - pub fn add_model(&mut self, build_model: F) -> ModelHandle - where - T: Entity, - F: FnOnce(&mut ModelContext) -> T, - { - self.cx.borrow_mut().add_model(build_model) - } - - pub fn add_window(&mut self, build_root_view: F) -> (usize, ViewHandle) - where - T: View, - F: FnOnce(&mut ViewContext) -> T, - { - let (window_id, view) = self - .cx - .borrow_mut() - .add_window(Default::default(), build_root_view); - self.simulate_window_activation(Some(window_id)); - (window_id, view) - } - - pub fn add_view( - &mut self, - parent_handle: impl Into, - build_view: F, - ) -> ViewHandle - where - T: View, - F: FnOnce(&mut ViewContext) -> T, - { - self.cx.borrow_mut().add_view(parent_handle, build_view) - } - - pub fn window_ids(&self) -> Vec { - self.cx.borrow().window_ids().collect() - } - - pub fn root_view(&self, window_id: usize) -> Option> { - self.cx.borrow().root_view(window_id) - } - - pub fn read T>(&self, callback: F) -> T { - callback(self.cx.borrow().as_ref()) - } - - pub fn update T>(&mut self, callback: F) -> T { - let mut state = self.cx.borrow_mut(); - // Don't increment pending flushes in order for effects to be flushed before the callback - // completes, which is helpful in tests. - let result = callback(&mut *state); - // Flush effects after the callback just in case there are any. This can happen in edge - // cases such as the closure dropping handles. - state.flush_effects(); - result - } - - pub fn render(&mut self, handle: &ViewHandle, f: F) -> T - where - F: FnOnce(&mut V, &mut RenderContext) -> T, - V: View, - { - handle.update(&mut *self.cx.borrow_mut(), |view, cx| { - let mut render_cx = RenderContext { - app: cx, - window_id: handle.window_id(), - view_id: handle.id(), - view_type: PhantomData, - titlebar_height: 0., - hovered_region_ids: Default::default(), - clicked_region_ids: None, - refreshing: false, - appearance: Appearance::Light, - }; - f(view, &mut render_cx) - }) - } - - pub fn to_async(&self) -> AsyncAppContext { - AsyncAppContext(self.cx.clone()) - } - - pub fn font_cache(&self) -> Arc { - self.cx.borrow().cx.font_cache.clone() - } - - pub fn foreground_platform(&self) -> Rc { - self.foreground_platform.clone() - } - - pub fn platform(&self) -> Arc { - self.cx.borrow().cx.platform.clone() - } - - pub fn foreground(&self) -> Rc { - self.cx.borrow().foreground().clone() - } - - pub fn background(&self) -> Arc { - self.cx.borrow().background().clone() - } - - pub fn spawn(&self, f: F) -> Task - where - F: FnOnce(AsyncAppContext) -> Fut, - Fut: 'static + Future, - T: 'static, - { - let foreground = self.foreground(); - let future = f(self.to_async()); - let cx = self.to_async(); - foreground.spawn(async move { - let result = future.await; - cx.0.borrow_mut().flush_effects(); - result - }) - } - - pub fn simulate_new_path_selection(&self, result: impl FnOnce(PathBuf) -> Option) { - self.foreground_platform.simulate_new_path_selection(result); - } - - pub fn did_prompt_for_new_path(&self) -> bool { - self.foreground_platform.as_ref().did_prompt_for_new_path() - } - - pub fn simulate_prompt_answer(&self, window_id: usize, answer: usize) { - use postage::prelude::Sink as _; - - let mut done_tx = self - .window_mut(window_id) - .pending_prompts - .borrow_mut() - .pop_front() - .expect("prompt was not called"); - let _ = done_tx.try_send(answer); - } - - pub fn has_pending_prompt(&self, window_id: usize) -> bool { - let window = self.window_mut(window_id); - let prompts = window.pending_prompts.borrow_mut(); - !prompts.is_empty() - } - - pub fn current_window_title(&self, window_id: usize) -> Option { - self.window_mut(window_id).title.clone() - } - - pub fn simulate_window_close(&self, window_id: usize) -> bool { - let handler = self.window_mut(window_id).should_close_handler.take(); - if let Some(mut handler) = handler { - let should_close = handler(); - self.window_mut(window_id).should_close_handler = Some(handler); - should_close - } else { - false - } - } - - pub fn simulate_window_activation(&self, to_activate: Option) { - let mut handlers = BTreeMap::new(); - { - let mut cx = self.cx.borrow_mut(); - for (window_id, (_, window)) in &mut cx.presenters_and_platform_windows { - let window = window - .as_any_mut() - .downcast_mut::() - .unwrap(); - handlers.insert( - *window_id, - mem::take(&mut window.active_status_change_handlers), - ); - } - }; - let mut handlers = handlers.into_iter().collect::>(); - handlers.sort_unstable_by_key(|(window_id, _)| Some(*window_id) == to_activate); - - for (window_id, mut window_handlers) in handlers { - for window_handler in &mut window_handlers { - window_handler(Some(window_id) == to_activate); - } - - self.window_mut(window_id) - .active_status_change_handlers - .extend(window_handlers); - } - } - - pub fn is_window_edited(&self, window_id: usize) -> bool { - self.window_mut(window_id).edited - } - - pub fn leak_detector(&self) -> Arc> { - self.cx.borrow().leak_detector() - } - - pub fn assert_dropped(&self, handle: impl WeakHandle) { - self.cx - .borrow() - .leak_detector() - .lock() - .assert_dropped(handle.id()) - } - - fn window_mut(&self, window_id: usize) -> std::cell::RefMut { - std::cell::RefMut::map(self.cx.borrow_mut(), |state| { - let (_, window) = state - .presenters_and_platform_windows - .get_mut(&window_id) - .unwrap(); - let test_window = window - .as_any_mut() - .downcast_mut::() - .unwrap(); - test_window - }) - } - - pub fn set_condition_duration(&mut self, duration: Option) { - self.condition_duration = duration; - } - - pub fn condition_duration(&self) -> Duration { - self.condition_duration.unwrap_or_else(|| { - if std::env::var("CI").is_ok() { - Duration::from_secs(2) - } else { - Duration::from_millis(500) - } - }) - } - - pub fn assert_clipboard_content(&mut self, expected_content: Option<&str>) { - self.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 AsyncAppContext { pub fn spawn(&self, f: F) -> Task where @@ -894,60 +571,6 @@ impl ReadViewWith for AsyncAppContext { } } -#[cfg(any(test, feature = "test-support"))] -impl UpdateModel for TestAppContext { - fn update_model( - &mut self, - handle: &ModelHandle, - update: &mut dyn FnMut(&mut T, &mut ModelContext) -> O, - ) -> O { - self.cx.borrow_mut().update_model(handle, update) - } -} - -#[cfg(any(test, feature = "test-support"))] -impl ReadModelWith for TestAppContext { - fn read_model_with( - &self, - handle: &ModelHandle, - read: &mut dyn FnMut(&E, &AppContext) -> T, - ) -> T { - let cx = self.cx.borrow(); - let cx = cx.as_ref(); - read(handle.read(cx), cx) - } -} - -#[cfg(any(test, feature = "test-support"))] -impl UpdateView for TestAppContext { - fn update_view( - &mut self, - handle: &ViewHandle, - update: &mut dyn FnMut(&mut T, &mut ViewContext) -> S, - ) -> S - where - T: View, - { - self.cx.borrow_mut().update_view(handle, update) - } -} - -#[cfg(any(test, feature = "test-support"))] -impl ReadViewWith for TestAppContext { - fn read_view_with( - &self, - handle: &ViewHandle, - read: &mut dyn FnMut(&V, &AppContext) -> T, - ) -> T - where - V: View, - { - let cx = self.cx.borrow(); - let cx = cx.as_ref(); - read(handle.read(cx), cx) - } -} - type ActionCallback = dyn FnMut(&mut dyn AnyView, &dyn Action, &mut MutableAppContext, usize, usize); type GlobalActionCallback = dyn FnMut(&dyn Action, &mut MutableAppContext); @@ -4446,117 +4069,6 @@ impl ModelHandle { update(model, cx) }) } - - #[cfg(any(test, feature = "test-support"))] - pub fn next_notification(&self, cx: &TestAppContext) -> impl Future { - let (tx, mut rx) = futures::channel::mpsc::unbounded(); - let mut cx = cx.cx.borrow_mut(); - let subscription = cx.observe(self, move |_, _| { - tx.unbounded_send(()).ok(); - }); - - let duration = if std::env::var("CI").is_ok() { - Duration::from_secs(5) - } else { - Duration::from_secs(1) - }; - - async move { - let notification = crate::util::timeout(duration, rx.next()) - .await - .expect("next notification timed out"); - drop(subscription); - notification.expect("model dropped while test was waiting for its next notification") - } - } - - #[cfg(any(test, feature = "test-support"))] - pub fn next_event(&self, cx: &TestAppContext) -> impl Future - where - T::Event: Clone, - { - let (tx, mut rx) = futures::channel::mpsc::unbounded(); - let mut cx = cx.cx.borrow_mut(); - let subscription = cx.subscribe(self, move |_, event, _| { - tx.unbounded_send(event.clone()).ok(); - }); - - let duration = if std::env::var("CI").is_ok() { - Duration::from_secs(5) - } else { - Duration::from_secs(1) - }; - - cx.foreground.start_waiting(); - async move { - let event = crate::util::timeout(duration, rx.next()) - .await - .expect("next event timed out"); - drop(subscription); - event.expect("model dropped while test was waiting for its next event") - } - } - - #[cfg(any(test, feature = "test-support"))] - pub fn condition( - &self, - cx: &TestAppContext, - mut predicate: impl FnMut(&T, &AppContext) -> bool, - ) -> impl Future { - let (tx, mut rx) = futures::channel::mpsc::unbounded(); - - let mut cx = cx.cx.borrow_mut(); - let subscriptions = ( - cx.observe(self, { - let tx = tx.clone(); - move |_, _| { - tx.unbounded_send(()).ok(); - } - }), - cx.subscribe(self, { - move |_, _, _| { - tx.unbounded_send(()).ok(); - } - }), - ); - - let cx = cx.weak_self.as_ref().unwrap().upgrade().unwrap(); - let handle = self.downgrade(); - let duration = if std::env::var("CI").is_ok() { - Duration::from_secs(5) - } else { - Duration::from_secs(1) - }; - - async move { - crate::util::timeout(duration, async move { - loop { - { - let cx = cx.borrow(); - let cx = cx.as_ref(); - if predicate( - handle - .upgrade(cx) - .expect("model dropped with pending condition") - .read(cx), - cx, - ) { - break; - } - } - - cx.borrow().foreground().start_waiting(); - rx.next() - .await - .expect("model dropped with pending condition"); - cx.borrow().foreground().finish_waiting(); - } - }) - .await - .expect("condition timed out"); - drop(subscriptions); - } - } } impl Clone for ModelHandle { @@ -4789,93 +4301,6 @@ impl ViewHandle { cx.focused_view_id(self.window_id) .map_or(false, |focused_id| focused_id == self.view_id) } - - #[cfg(any(test, feature = "test-support"))] - pub fn next_notification(&self, cx: &TestAppContext) -> impl Future { - use postage::prelude::{Sink as _, Stream as _}; - - let (mut tx, mut rx) = postage::mpsc::channel(1); - let mut cx = cx.cx.borrow_mut(); - let subscription = cx.observe(self, move |_, _| { - tx.try_send(()).ok(); - }); - - let duration = if std::env::var("CI").is_ok() { - Duration::from_secs(5) - } else { - Duration::from_secs(1) - }; - - async move { - let notification = crate::util::timeout(duration, rx.recv()) - .await - .expect("next notification timed out"); - drop(subscription); - notification.expect("model dropped while test was waiting for its next notification") - } - } - - #[cfg(any(test, feature = "test-support"))] - pub fn condition( - &self, - cx: &TestAppContext, - mut predicate: impl FnMut(&T, &AppContext) -> bool, - ) -> impl Future { - use postage::prelude::{Sink as _, Stream as _}; - - let (tx, mut rx) = postage::mpsc::channel(1024); - let timeout_duration = cx.condition_duration(); - - let mut cx = cx.cx.borrow_mut(); - let subscriptions = self.update(&mut *cx, |_, cx| { - ( - cx.observe(self, { - let mut tx = tx.clone(); - move |_, _, _| { - tx.blocking_send(()).ok(); - } - }), - cx.subscribe(self, { - let mut tx = tx.clone(); - move |_, _, _, _| { - tx.blocking_send(()).ok(); - } - }), - ) - }); - - let cx = cx.weak_self.as_ref().unwrap().upgrade().unwrap(); - let handle = self.downgrade(); - - async move { - crate::util::timeout(timeout_duration, async move { - loop { - { - let cx = cx.borrow(); - let cx = cx.as_ref(); - if predicate( - handle - .upgrade(cx) - .expect("view dropped with pending condition") - .read(cx), - cx, - ) { - break; - } - } - - cx.borrow().foreground().start_waiting(); - rx.recv() - .await - .expect("view dropped with pending condition"); - cx.borrow().foreground().finish_waiting(); - } - }) - .await - .expect("condition timed out"); - drop(subscriptions); - } - } } impl Clone for ViewHandle { diff --git a/crates/gpui/src/app/test_app_context.rs b/crates/gpui/src/app/test_app_context.rs new file mode 100644 index 0000000000..477c316f71 --- /dev/null +++ b/crates/gpui/src/app/test_app_context.rs @@ -0,0 +1,655 @@ +use std::{ + cell::RefCell, + marker::PhantomData, + mem, + path::PathBuf, + rc::Rc, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, + time::Duration, +}; + +use futures::Future; +use itertools::Itertools; +use parking_lot::{Mutex, RwLock}; +use smol::stream::StreamExt; + +use crate::{ + executor, keymap::Keystroke, platform, Action, AnyViewHandle, AppContext, Appearance, Entity, + Event, FontCache, InputHandler, KeyDownEvent, LeakDetector, ModelContext, ModelHandle, + MutableAppContext, Platform, ReadModelWith, ReadViewWith, RenderContext, Task, UpdateModel, + UpdateView, View, ViewContext, ViewHandle, WeakHandle, WindowInputHandler, +}; +use collections::BTreeMap; + +use super::{AsyncAppContext, RefCounts}; + +pub struct TestAppContext { + cx: Rc>, + foreground_platform: Rc, + condition_duration: Option, + pub function_name: String, + assertion_context: AssertionContextManager, +} + +impl TestAppContext { + pub fn new( + foreground_platform: Rc, + platform: Arc, + foreground: Rc, + background: Arc, + font_cache: Arc, + leak_detector: Arc>, + first_entity_id: usize, + function_name: String, + ) -> Self { + let mut cx = MutableAppContext::new( + foreground, + background, + platform, + foreground_platform.clone(), + font_cache, + RefCounts { + #[cfg(any(test, feature = "test-support"))] + leak_detector, + ..Default::default() + }, + (), + ); + cx.next_entity_id = first_entity_id; + let cx = TestAppContext { + cx: Rc::new(RefCell::new(cx)), + foreground_platform, + condition_duration: None, + function_name, + assertion_context: AssertionContextManager::new(), + }; + cx.cx.borrow_mut().weak_self = Some(Rc::downgrade(&cx.cx)); + cx + } + + pub fn dispatch_action(&self, window_id: usize, action: A) { + let mut cx = self.cx.borrow_mut(); + if let Some(view_id) = cx.focused_view_id(window_id) { + cx.handle_dispatch_action_from_effect(window_id, Some(view_id), &action); + } + } + + pub fn dispatch_global_action(&self, action: A) { + self.cx.borrow_mut().dispatch_global_action(action); + } + + pub fn dispatch_keystroke(&mut self, window_id: usize, keystroke: Keystroke, is_held: bool) { + let handled = self.cx.borrow_mut().update(|cx| { + let presenter = cx + .presenters_and_platform_windows + .get(&window_id) + .unwrap() + .0 + .clone(); + + if cx.dispatch_keystroke(window_id, &keystroke) { + return true; + } + + if presenter.borrow_mut().dispatch_event( + Event::KeyDown(KeyDownEvent { + keystroke: keystroke.clone(), + is_held, + }), + false, + cx, + ) { + return true; + } + + false + }); + + if !handled && !keystroke.cmd && !keystroke.ctrl { + WindowInputHandler { + app: self.cx.clone(), + window_id, + } + .replace_text_in_range(None, &keystroke.key) + } + } + + pub fn add_model(&mut self, build_model: F) -> ModelHandle + where + T: Entity, + F: FnOnce(&mut ModelContext) -> T, + { + self.cx.borrow_mut().add_model(build_model) + } + + pub fn add_window(&mut self, build_root_view: F) -> (usize, ViewHandle) + where + T: View, + F: FnOnce(&mut ViewContext) -> T, + { + let (window_id, view) = self + .cx + .borrow_mut() + .add_window(Default::default(), build_root_view); + self.simulate_window_activation(Some(window_id)); + (window_id, view) + } + + pub fn add_view( + &mut self, + parent_handle: impl Into, + build_view: F, + ) -> ViewHandle + where + T: View, + F: FnOnce(&mut ViewContext) -> T, + { + self.cx.borrow_mut().add_view(parent_handle, build_view) + } + + pub fn window_ids(&self) -> Vec { + self.cx.borrow().window_ids().collect() + } + + pub fn root_view(&self, window_id: usize) -> Option> { + self.cx.borrow().root_view(window_id) + } + + pub fn read T>(&self, callback: F) -> T { + callback(self.cx.borrow().as_ref()) + } + + pub fn update T>(&mut self, callback: F) -> T { + let mut state = self.cx.borrow_mut(); + // Don't increment pending flushes in order for effects to be flushed before the callback + // completes, which is helpful in tests. + let result = callback(&mut *state); + // Flush effects after the callback just in case there are any. This can happen in edge + // cases such as the closure dropping handles. + state.flush_effects(); + result + } + + pub fn render(&mut self, handle: &ViewHandle, f: F) -> T + where + F: FnOnce(&mut V, &mut RenderContext) -> T, + V: View, + { + handle.update(&mut *self.cx.borrow_mut(), |view, cx| { + let mut render_cx = RenderContext { + app: cx, + window_id: handle.window_id(), + view_id: handle.id(), + view_type: PhantomData, + titlebar_height: 0., + hovered_region_ids: Default::default(), + clicked_region_ids: None, + refreshing: false, + appearance: Appearance::Light, + }; + f(view, &mut render_cx) + }) + } + + pub fn to_async(&self) -> AsyncAppContext { + AsyncAppContext(self.cx.clone()) + } + + pub fn font_cache(&self) -> Arc { + self.cx.borrow().cx.font_cache.clone() + } + + pub fn foreground_platform(&self) -> Rc { + self.foreground_platform.clone() + } + + pub fn platform(&self) -> Arc { + self.cx.borrow().cx.platform.clone() + } + + pub fn foreground(&self) -> Rc { + self.cx.borrow().foreground().clone() + } + + pub fn background(&self) -> Arc { + self.cx.borrow().background().clone() + } + + pub fn spawn(&self, f: F) -> Task + where + F: FnOnce(AsyncAppContext) -> Fut, + Fut: 'static + Future, + T: 'static, + { + let foreground = self.foreground(); + let future = f(self.to_async()); + let cx = self.to_async(); + foreground.spawn(async move { + let result = future.await; + cx.0.borrow_mut().flush_effects(); + result + }) + } + + pub fn simulate_new_path_selection(&self, result: impl FnOnce(PathBuf) -> Option) { + self.foreground_platform.simulate_new_path_selection(result); + } + + pub fn did_prompt_for_new_path(&self) -> bool { + self.foreground_platform.as_ref().did_prompt_for_new_path() + } + + pub fn simulate_prompt_answer(&self, window_id: usize, answer: usize) { + use postage::prelude::Sink as _; + + let mut done_tx = self + .window_mut(window_id) + .pending_prompts + .borrow_mut() + .pop_front() + .expect("prompt was not called"); + let _ = done_tx.try_send(answer); + } + + pub fn has_pending_prompt(&self, window_id: usize) -> bool { + let window = self.window_mut(window_id); + let prompts = window.pending_prompts.borrow_mut(); + !prompts.is_empty() + } + + pub fn current_window_title(&self, window_id: usize) -> Option { + self.window_mut(window_id).title.clone() + } + + pub fn simulate_window_close(&self, window_id: usize) -> bool { + let handler = self.window_mut(window_id).should_close_handler.take(); + if let Some(mut handler) = handler { + let should_close = handler(); + self.window_mut(window_id).should_close_handler = Some(handler); + should_close + } else { + false + } + } + + pub fn simulate_window_activation(&self, to_activate: Option) { + let mut handlers = BTreeMap::new(); + { + let mut cx = self.cx.borrow_mut(); + for (window_id, (_, window)) in &mut cx.presenters_and_platform_windows { + let window = window + .as_any_mut() + .downcast_mut::() + .unwrap(); + handlers.insert( + *window_id, + mem::take(&mut window.active_status_change_handlers), + ); + } + }; + let mut handlers = handlers.into_iter().collect::>(); + handlers.sort_unstable_by_key(|(window_id, _)| Some(*window_id) == to_activate); + + for (window_id, mut window_handlers) in handlers { + for window_handler in &mut window_handlers { + window_handler(Some(window_id) == to_activate); + } + + self.window_mut(window_id) + .active_status_change_handlers + .extend(window_handlers); + } + } + + pub fn is_window_edited(&self, window_id: usize) -> bool { + self.window_mut(window_id).edited + } + + pub fn leak_detector(&self) -> Arc> { + self.cx.borrow().leak_detector() + } + + pub fn assert_dropped(&self, handle: impl WeakHandle) { + self.cx + .borrow() + .leak_detector() + .lock() + .assert_dropped(handle.id()) + } + + fn window_mut(&self, window_id: usize) -> std::cell::RefMut { + std::cell::RefMut::map(self.cx.borrow_mut(), |state| { + let (_, window) = state + .presenters_and_platform_windows + .get_mut(&window_id) + .unwrap(); + let test_window = window + .as_any_mut() + .downcast_mut::() + .unwrap(); + test_window + }) + } + + pub fn set_condition_duration(&mut self, duration: Option) { + self.condition_duration = duration; + } + + pub fn condition_duration(&self) -> Duration { + self.condition_duration.unwrap_or_else(|| { + if std::env::var("CI").is_ok() { + Duration::from_secs(2) + } else { + Duration::from_millis(500) + } + }) + } + + pub fn assert_clipboard_content(&mut self, expected_content: Option<&str>) { + self.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); + }) + } + + pub fn add_assertion_context(&self, context: String) -> ContextHandle { + self.assertion_context.add_context(context) + } + + pub fn assertion_context(&self) -> String { + self.assertion_context.context() + } +} + +impl UpdateModel for TestAppContext { + fn update_model( + &mut self, + handle: &ModelHandle, + update: &mut dyn FnMut(&mut T, &mut ModelContext) -> O, + ) -> O { + self.cx.borrow_mut().update_model(handle, update) + } +} + +impl ReadModelWith for TestAppContext { + fn read_model_with( + &self, + handle: &ModelHandle, + read: &mut dyn FnMut(&E, &AppContext) -> T, + ) -> T { + let cx = self.cx.borrow(); + let cx = cx.as_ref(); + read(handle.read(cx), cx) + } +} + +impl UpdateView for TestAppContext { + fn update_view( + &mut self, + handle: &ViewHandle, + update: &mut dyn FnMut(&mut T, &mut ViewContext) -> S, + ) -> S + where + T: View, + { + self.cx.borrow_mut().update_view(handle, update) + } +} + +impl ReadViewWith for TestAppContext { + fn read_view_with( + &self, + handle: &ViewHandle, + read: &mut dyn FnMut(&V, &AppContext) -> T, + ) -> T + where + V: View, + { + let cx = self.cx.borrow(); + let cx = cx.as_ref(); + read(handle.read(cx), cx) + } +} + +impl ModelHandle { + pub fn next_notification(&self, cx: &TestAppContext) -> impl Future { + let (tx, mut rx) = futures::channel::mpsc::unbounded(); + let mut cx = cx.cx.borrow_mut(); + let subscription = cx.observe(self, move |_, _| { + tx.unbounded_send(()).ok(); + }); + + let duration = if std::env::var("CI").is_ok() { + Duration::from_secs(5) + } else { + Duration::from_secs(1) + }; + + async move { + let notification = crate::util::timeout(duration, rx.next()) + .await + .expect("next notification timed out"); + drop(subscription); + notification.expect("model dropped while test was waiting for its next notification") + } + } + + pub fn next_event(&self, cx: &TestAppContext) -> impl Future + where + T::Event: Clone, + { + let (tx, mut rx) = futures::channel::mpsc::unbounded(); + let mut cx = cx.cx.borrow_mut(); + let subscription = cx.subscribe(self, move |_, event, _| { + tx.unbounded_send(event.clone()).ok(); + }); + + let duration = if std::env::var("CI").is_ok() { + Duration::from_secs(5) + } else { + Duration::from_secs(1) + }; + + cx.foreground.start_waiting(); + async move { + let event = crate::util::timeout(duration, rx.next()) + .await + .expect("next event timed out"); + drop(subscription); + event.expect("model dropped while test was waiting for its next event") + } + } + + pub fn condition( + &self, + cx: &TestAppContext, + mut predicate: impl FnMut(&T, &AppContext) -> bool, + ) -> impl Future { + let (tx, mut rx) = futures::channel::mpsc::unbounded(); + + let mut cx = cx.cx.borrow_mut(); + let subscriptions = ( + cx.observe(self, { + let tx = tx.clone(); + move |_, _| { + tx.unbounded_send(()).ok(); + } + }), + cx.subscribe(self, { + move |_, _, _| { + tx.unbounded_send(()).ok(); + } + }), + ); + + let cx = cx.weak_self.as_ref().unwrap().upgrade().unwrap(); + let handle = self.downgrade(); + let duration = if std::env::var("CI").is_ok() { + Duration::from_secs(5) + } else { + Duration::from_secs(1) + }; + + async move { + crate::util::timeout(duration, async move { + loop { + { + let cx = cx.borrow(); + let cx = cx.as_ref(); + if predicate( + handle + .upgrade(cx) + .expect("model dropped with pending condition") + .read(cx), + cx, + ) { + break; + } + } + + cx.borrow().foreground().start_waiting(); + rx.next() + .await + .expect("model dropped with pending condition"); + cx.borrow().foreground().finish_waiting(); + } + }) + .await + .expect("condition timed out"); + drop(subscriptions); + } + } +} + +impl ViewHandle { + pub fn next_notification(&self, cx: &TestAppContext) -> impl Future { + use postage::prelude::{Sink as _, Stream as _}; + + let (mut tx, mut rx) = postage::mpsc::channel(1); + let mut cx = cx.cx.borrow_mut(); + let subscription = cx.observe(self, move |_, _| { + tx.try_send(()).ok(); + }); + + let duration = if std::env::var("CI").is_ok() { + Duration::from_secs(5) + } else { + Duration::from_secs(1) + }; + + async move { + let notification = crate::util::timeout(duration, rx.recv()) + .await + .expect("next notification timed out"); + drop(subscription); + notification.expect("model dropped while test was waiting for its next notification") + } + } + + pub fn condition( + &self, + cx: &TestAppContext, + mut predicate: impl FnMut(&T, &AppContext) -> bool, + ) -> impl Future { + use postage::prelude::{Sink as _, Stream as _}; + + let (tx, mut rx) = postage::mpsc::channel(1024); + let timeout_duration = cx.condition_duration(); + + let mut cx = cx.cx.borrow_mut(); + let subscriptions = self.update(&mut *cx, |_, cx| { + ( + cx.observe(self, { + let mut tx = tx.clone(); + move |_, _, _| { + tx.blocking_send(()).ok(); + } + }), + cx.subscribe(self, { + let mut tx = tx.clone(); + move |_, _, _, _| { + tx.blocking_send(()).ok(); + } + }), + ) + }); + + let cx = cx.weak_self.as_ref().unwrap().upgrade().unwrap(); + let handle = self.downgrade(); + + async move { + crate::util::timeout(timeout_duration, async move { + loop { + { + let cx = cx.borrow(); + let cx = cx.as_ref(); + if predicate( + handle + .upgrade(cx) + .expect("view dropped with pending condition") + .read(cx), + cx, + ) { + break; + } + } + + cx.borrow().foreground().start_waiting(); + rx.recv() + .await + .expect("view dropped with pending condition"); + cx.borrow().foreground().finish_waiting(); + } + }) + .await + .expect("condition timed out"); + drop(subscriptions); + } + } +} + +#[derive(Clone)] +pub struct AssertionContextManager { + id: Arc, + contexts: Arc>>, +} + +impl AssertionContextManager { + pub fn new() -> Self { + Self { + id: Arc::new(AtomicUsize::new(0)), + contexts: Arc::new(RwLock::new(BTreeMap::new())), + } + } + + pub fn add_context(&self, context: String) -> ContextHandle { + let id = self.id.fetch_add(1, Ordering::Relaxed); + let mut contexts = self.contexts.write(); + contexts.insert(id, context); + ContextHandle { + id, + manager: self.clone(), + } + } + + pub fn context(&self) -> String { + let contexts = self.contexts.read(); + format!("\n{}\n", contexts.values().join("\n")) + } +} + +pub struct ContextHandle { + id: usize, + manager: AssertionContextManager, +} + +impl Drop for ContextHandle { + fn drop(&mut self) { + let mut contexts = self.manager.contexts.write(); + contexts.remove(&self.id); + } +} diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 25c1d5ac8e..9fc2c16497 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -65,6 +65,7 @@ pub trait Platform: Send + Sync { fn delete_credentials(&self, url: &str) -> Result<()>; fn set_cursor_style(&self, style: CursorStyle); + fn should_auto_hide_scrollbars(&self) -> bool; fn local_timezone(&self) -> UtcOffset; diff --git a/crates/gpui/src/platform/mac/platform.rs b/crates/gpui/src/platform/mac/platform.rs index f35d5d6935..a27220cf2e 100644 --- a/crates/gpui/src/platform/mac/platform.rs +++ b/crates/gpui/src/platform/mac/platform.rs @@ -709,6 +709,16 @@ impl platform::Platform for MacPlatform { } } + fn should_auto_hide_scrollbars(&self) -> bool { + #[allow(non_upper_case_globals)] + const NSScrollerStyleOverlay: NSInteger = 1; + + unsafe { + let style: NSInteger = msg_send![class!(NSScroller), preferredScrollerStyle]; + style == NSScrollerStyleOverlay + } + } + fn local_timezone(&self) -> UtcOffset { unsafe { let local_timezone: id = msg_send![class!(NSTimeZone), localTimeZone]; diff --git a/crates/gpui/src/platform/test.rs b/crates/gpui/src/platform/test.rs index c3f037fe86..3c2e23bbd3 100644 --- a/crates/gpui/src/platform/test.rs +++ b/crates/gpui/src/platform/test.rs @@ -181,6 +181,10 @@ impl super::Platform for Platform { *self.cursor.lock() = style; } + fn should_auto_hide_scrollbars(&self) -> bool { + false + } + fn local_timezone(&self) -> UtcOffset { UtcOffset::UTC } diff --git a/crates/gpui/src/test.rs b/crates/gpui/src/test.rs index 6cfb4cf2b6..e76b094c9a 100644 --- a/crates/gpui/src/test.rs +++ b/crates/gpui/src/test.rs @@ -37,6 +37,7 @@ pub fn run_test( u64, bool, )), + fn_name: String, ) { // let _profiler = dhat::Profiler::new_heap(); @@ -78,6 +79,7 @@ pub fn run_test( font_cache.clone(), leak_detector.clone(), 0, + fn_name.clone(), ); cx.update(|cx| { test_fn( diff --git a/crates/gpui_macros/src/gpui_macros.rs b/crates/gpui_macros/src/gpui_macros.rs index 32a821f4c8..b43bedc643 100644 --- a/crates/gpui_macros/src/gpui_macros.rs +++ b/crates/gpui_macros/src/gpui_macros.rs @@ -117,6 +117,7 @@ pub fn test(args: TokenStream, function: TokenStream) -> TokenStream { cx.font_cache().clone(), cx.leak_detector(), #first_entity_id, + stringify!(#outer_fn_name).to_string(), ); )); cx_teardowns.extend(quote!( @@ -149,7 +150,8 @@ pub fn test(args: TokenStream, function: TokenStream) -> TokenStream { #cx_vars cx.foreground().run(#inner_fn_name(#inner_fn_args)); #cx_teardowns - } + }, + stringify!(#outer_fn_name).to_string(), ); } } @@ -187,7 +189,8 @@ pub fn test(args: TokenStream, function: TokenStream) -> TokenStream { #num_iterations as u64, #starting_seed as u64, #max_retries, - &mut |cx, _, _, seed, is_last_iteration| #inner_fn_name(#inner_fn_args) + &mut |cx, _, _, seed, is_last_iteration| #inner_fn_name(#inner_fn_args), + stringify!(#outer_fn_name).to_string(), ); } } diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index 37ec279d02..6e411c010f 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -554,6 +554,15 @@ pub struct Editor { pub link_definition: HighlightStyle, pub composition_mark: HighlightStyle, pub jump_icon: Interactive, + pub scrollbar: Scrollbar, +} + +#[derive(Clone, Deserialize, Default)] +pub struct Scrollbar { + pub track: ContainerStyle, + pub thumb: ContainerStyle, + pub width: f32, + pub min_height_factor: f32, } #[derive(Clone, Deserialize, Default)] diff --git a/crates/vim/Cargo.toml b/crates/vim/Cargo.toml index bf094c30fa..85c9636c69 100644 --- a/crates/vim/Cargo.toml +++ b/crates/vim/Cargo.toml @@ -7,7 +7,20 @@ edition = "2021" path = "src/vim.rs" doctest = false +[features] +neovim = ["nvim-rs", "async-compat", "async-trait", "tokio"] + [dependencies] +serde = { version = "1.0", features = ["derive", "rc"] } +itertools = "0.10" +log = { version = "0.4.16", features = ["kv_unstable_serde"] } + +async-compat = { version = "0.2.1", "optional" = true } +async-trait = { version = "0.1", "optional" = true } +nvim-rs = { git = "https://github.com/KillTheMule/nvim-rs", branch = "master", features = ["use_tokio"], optional = true } +tokio = { version = "1.15", "optional" = true } +serde_json = { version = "1.0", features = ["preserve_order"] } + assets = { path = "../assets" } collections = { path = "../collections" } command_palette = { path = "../command_palette" } @@ -16,14 +29,14 @@ gpui = { path = "../gpui" } language = { path = "../language" } rope = { path = "../rope" } search = { path = "../search" } -serde = { version = "1.0", features = ["derive", "rc"] } settings = { path = "../settings" } workspace = { path = "../workspace" } -itertools = "0.10" -log = { version = "0.4.16", features = ["kv_unstable_serde"] } [dev-dependencies] indoc = "1.0.4" +parking_lot = "0.11.1" +lazy_static = "1.4" + editor = { path = "../editor", features = ["test-support"] } gpui = { path = "../gpui", features = ["test-support"] } language = { path = "../language", features = ["test-support"] } diff --git a/crates/vim/src/insert.rs b/crates/vim/src/insert.rs index a1d1c7b404..05cd2af1d9 100644 --- a/crates/vim/src/insert.rs +++ b/crates/vim/src/insert.rs @@ -26,7 +26,7 @@ fn normal_before(_: &mut Workspace, _: &NormalBefore, cx: &mut ViewContext normal_motion(motion, cx), - Mode::Visual { .. } => visual_motion(motion, cx), + Mode::Normal => normal_motion(motion, operator, times, cx), + Mode::Visual { .. } => visual_motion(motion, times, cx), Mode::Insert => { // Shouldn't execute a motion in insert mode. Ignoring } } + Vim::update(cx, |vim, cx| vim.clear_operator(cx)); } // Motion handling is specified here: @@ -150,30 +155,32 @@ impl Motion { map: &DisplaySnapshot, point: DisplayPoint, goal: SelectionGoal, + times: usize, ) -> (DisplayPoint, SelectionGoal) { use Motion::*; match self { - Left => (left(map, point), SelectionGoal::None), - Down => movement::down(map, point, goal, true), - Up => movement::up(map, point, goal, true), - Right => (right(map, point), SelectionGoal::None), + 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), + Right => (right(map, point, times), SelectionGoal::None), NextWordStart { ignore_punctuation } => ( - next_word_start(map, point, ignore_punctuation), + next_word_start(map, point, ignore_punctuation, times), SelectionGoal::None, ), NextWordEnd { ignore_punctuation } => ( - next_word_end(map, point, ignore_punctuation), + next_word_end(map, point, ignore_punctuation, times), SelectionGoal::None, ), PreviousWordStart { ignore_punctuation } => ( - previous_word_start(map, point, ignore_punctuation), + 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), CurrentLine => (end_of_line(map, point), SelectionGoal::None), - StartOfDocument => (start_of_document(map, point), SelectionGoal::None), + StartOfDocument => (start_of_document(map, point, times), SelectionGoal::None), EndOfDocument => (end_of_document(map, point), SelectionGoal::None), Matching => (matching(map, point), SelectionGoal::None), } @@ -184,9 +191,10 @@ impl Motion { self, map: &DisplaySnapshot, selection: &mut Selection, + times: usize, expand_to_surrounding_newline: bool, ) { - let (head, goal) = self.move_point(map, selection.head(), selection.goal); + let (head, goal) = self.move_point(map, selection.head(), selection.goal, times); selection.set_head(head, goal); if self.linewise() { @@ -206,7 +214,7 @@ impl Motion { } } - selection.end = map.next_line_boundary(selection.end.to_point(map)).1; + (_, selection.end) = map.next_line_boundary(selection.end.to_point(map)); } else { // If the motion is exclusive and the end of the motion is in column 1, the // end of the motion is moved to the end of the previous line and the motion @@ -234,95 +242,151 @@ impl Motion { } } -fn left(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint { - *point.column_mut() = point.column().saturating_sub(1); - map.clip_point(point, Bias::Left) -} - -fn right(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint { - *point.column_mut() += 1; - map.clip_point(point, Bias::Right) -} - -fn next_word_start( - map: &DisplaySnapshot, - point: DisplayPoint, - ignore_punctuation: bool, -) -> DisplayPoint { - let mut crossed_newline = false; - movement::find_boundary(map, point, |left, right| { - let left_kind = char_kind(left).coerce_punctuation(ignore_punctuation); - let right_kind = char_kind(right).coerce_punctuation(ignore_punctuation); - let at_newline = right == '\n'; - - let found = (left_kind != right_kind && !right.is_whitespace()) - || at_newline && crossed_newline - || at_newline && left == '\n'; // Prevents skipping repeated empty lines - - if at_newline { - crossed_newline = true; +fn left(map: &DisplaySnapshot, mut point: DisplayPoint, times: usize) -> DisplayPoint { + for _ in 0..times { + *point.column_mut() = point.column().saturating_sub(1); + point = map.clip_point(point, Bias::Right); + if point.column() == 0 { + break; } - found - }) + } + point +} + +fn backspace(map: &DisplaySnapshot, mut point: DisplayPoint, times: usize) -> DisplayPoint { + for _ in 0..times { + point = movement::left(map, point); + } + point +} + +fn down( + map: &DisplaySnapshot, + mut point: DisplayPoint, + mut goal: SelectionGoal, + times: usize, +) -> (DisplayPoint, SelectionGoal) { + for _ in 0..times { + (point, goal) = movement::down(map, point, goal, true); + } + (point, goal) +} + +fn up( + 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) +} + +pub(crate) fn right(map: &DisplaySnapshot, mut point: DisplayPoint, times: usize) -> DisplayPoint { + for _ in 0..times { + let mut new_point = point; + *new_point.column_mut() += 1; + let new_point = map.clip_point(new_point, Bias::Right); + if point == new_point { + break; + } + point = new_point; + } + point +} + +pub(crate) fn next_word_start( + map: &DisplaySnapshot, + mut point: DisplayPoint, + ignore_punctuation: bool, + times: usize, +) -> DisplayPoint { + for _ in 0..times { + let mut crossed_newline = false; + point = movement::find_boundary(map, point, |left, right| { + let left_kind = char_kind(left).coerce_punctuation(ignore_punctuation); + let right_kind = char_kind(right).coerce_punctuation(ignore_punctuation); + let at_newline = right == '\n'; + + let found = (left_kind != right_kind && right_kind != CharKind::Whitespace) + || at_newline && crossed_newline + || at_newline && left == '\n'; // Prevents skipping repeated empty lines + + if at_newline { + crossed_newline = true; + } + found + }) + } + point } fn next_word_end( map: &DisplaySnapshot, mut point: DisplayPoint, ignore_punctuation: bool, + times: usize, ) -> DisplayPoint { - *point.column_mut() += 1; - point = movement::find_boundary(map, point, |left, right| { - let left_kind = char_kind(left).coerce_punctuation(ignore_punctuation); - let right_kind = char_kind(right).coerce_punctuation(ignore_punctuation); + for _ in 0..times { + *point.column_mut() += 1; + point = movement::find_boundary(map, point, |left, right| { + let left_kind = char_kind(left).coerce_punctuation(ignore_punctuation); + let right_kind = char_kind(right).coerce_punctuation(ignore_punctuation); - left_kind != right_kind && !left.is_whitespace() - }); - // find_boundary clips, so if the character after the next character is a newline or at the end of the document, we know - // we have backtraced already - if !map - .chars_at(point) - .nth(1) - .map(|c| c == '\n') - .unwrap_or(true) - { - *point.column_mut() = point.column().saturating_sub(1); + left_kind != right_kind && left_kind != CharKind::Whitespace + }); + + // find_boundary clips, so if the character after the next character is a newline or at the end of the document, we know + // we have backtraced already + if !map + .chars_at(point) + .nth(1) + .map(|(c, _)| c == '\n') + .unwrap_or(true) + { + *point.column_mut() = point.column().saturating_sub(1); + } + point = map.clip_point(point, Bias::Left); } - map.clip_point(point, Bias::Left) + point } fn previous_word_start( map: &DisplaySnapshot, mut point: DisplayPoint, ignore_punctuation: bool, + times: usize, ) -> DisplayPoint { - // This works even though find_preceding_boundary is called for every character in the line containing - // cursor because the newline is checked only once. - point = movement::find_preceding_boundary(map, point, |left, right| { - let left_kind = char_kind(left).coerce_punctuation(ignore_punctuation); - let right_kind = char_kind(right).coerce_punctuation(ignore_punctuation); + for _ in 0..times { + // This works even though find_preceding_boundary is called for every character in the line containing + // cursor because the newline is checked only once. + point = movement::find_preceding_boundary(map, point, |left, right| { + let left_kind = char_kind(left).coerce_punctuation(ignore_punctuation); + let right_kind = char_kind(right).coerce_punctuation(ignore_punctuation); - (left_kind != right_kind && !right.is_whitespace()) || left == '\n' - }); + (left_kind != right_kind && !right.is_whitespace()) || left == '\n' + }); + } point } -fn first_non_whitespace(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint { - let mut column = 0; - for ch in map.chars_at(DisplayPoint::new(point.row(), 0)) { +fn first_non_whitespace(map: &DisplaySnapshot, from: DisplayPoint) -> DisplayPoint { + let mut last_point = DisplayPoint::new(from.row(), 0); + for (ch, point) in map.chars_at(last_point) { if ch == '\n' { - return point; + return from; } + last_point = point; + if char_kind(ch) != CharKind::Whitespace { break; } - - column += ch.len_utf8() as u32; } - *point.column_mut() = column; - map.clip_point(point, Bias::Left) + map.clip_point(last_point, Bias::Left) } fn start_of_line(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint { @@ -333,8 +397,8 @@ fn end_of_line(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint { map.clip_point(map.next_line_boundary(point.to_point(map)).1, Bias::Left) } -fn start_of_document(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint { - let mut new_point = 0usize.to_display_point(map); +fn start_of_document(map: &DisplaySnapshot, point: DisplayPoint, line: usize) -> DisplayPoint { + let mut new_point = (line - 1).to_display_point(map); *new_point.column_mut() = point.column(); map.clip_point(new_point, Bias::Left) } diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index a06bcc54da..6741d8ac0b 100644 --- a/crates/vim/src/normal.rs +++ b/crates/vim/src/normal.rs @@ -6,18 +6,24 @@ use std::borrow::Cow; use crate::{ motion::Motion, + object::Object, state::{Mode, Operator}, Vim, }; -use change::init as change_init; -use collections::HashSet; -use editor::{Autoscroll, Bias, ClipboardSelection, DisplayPoint}; +use collections::{HashMap, HashSet}; +use editor::{ + display_map::ToDisplayPoint, Anchor, Autoscroll, Bias, ClipboardSelection, DisplayPoint, +}; use gpui::{actions, MutableAppContext, ViewContext}; use language::{AutoindentMode, SelectionGoal}; use rope::point::Point; use workspace::Workspace; -use self::{change::change_over, delete::delete_over, yank::yank_over}; +use self::{ + change::{change_motion, change_object}, + delete::{delete_motion, delete_object}, + yank::{yank_motion, yank_object}, +}; actions!( vim, @@ -44,48 +50,73 @@ pub fn init(cx: &mut MutableAppContext) { cx.add_action(insert_line_below); cx.add_action(|_: &mut Workspace, _: &DeleteLeft, cx| { Vim::update(cx, |vim, cx| { - delete_over(vim, Motion::Left, cx); + let times = vim.pop_number_operator(cx); + delete_motion(vim, Motion::Left, times, cx); }) }); cx.add_action(|_: &mut Workspace, _: &DeleteRight, cx| { Vim::update(cx, |vim, cx| { - delete_over(vim, Motion::Right, cx); + let times = vim.pop_number_operator(cx); + delete_motion(vim, Motion::Right, times, cx); }) }); cx.add_action(|_: &mut Workspace, _: &ChangeToEndOfLine, cx| { Vim::update(cx, |vim, cx| { - change_over(vim, Motion::EndOfLine, cx); + let times = vim.pop_number_operator(cx); + change_motion(vim, Motion::EndOfLine, times, cx); }) }); cx.add_action(|_: &mut Workspace, _: &DeleteToEndOfLine, cx| { Vim::update(cx, |vim, cx| { - delete_over(vim, Motion::EndOfLine, cx); + let times = vim.pop_number_operator(cx); + delete_motion(vim, Motion::EndOfLine, times, cx); }) }); cx.add_action(paste); - - change_init(cx); } -pub fn normal_motion(motion: Motion, cx: &mut MutableAppContext) { +pub fn normal_motion( + motion: Motion, + operator: Option, + times: usize, + cx: &mut MutableAppContext, +) { Vim::update(cx, |vim, cx| { - match vim.state.operator_stack.pop() { - None => move_cursor(vim, motion, cx), - Some(Operator::Namespace(_)) => { - // Can't do anything for a namespace operator. Ignoring + match operator { + None => move_cursor(vim, motion, times, cx), + Some(Operator::Change) => change_motion(vim, motion, times, cx), + Some(Operator::Delete) => delete_motion(vim, motion, times, cx), + Some(Operator::Yank) => yank_motion(vim, motion, times, cx), + _ => { + // Can't do anything for text objects or namespace operators. 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); }); } -fn move_cursor(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) { +pub fn normal_object(object: Object, cx: &mut MutableAppContext) { + Vim::update(cx, |vim, cx| { + match vim.state.operator_stack.pop() { + Some(Operator::Object { around }) => match vim.state.operator_stack.pop() { + Some(Operator::Change) => change_object(vim, object, around, cx), + Some(Operator::Delete) => delete_object(vim, object, around, cx), + Some(Operator::Yank) => yank_object(vim, object, around, cx), + _ => { + // Can't do anything for namespace operators. Ignoring + } + }, + _ => { + // Can't do anything with change/delete/yank and text objects. Ignoring + } + } + vim.clear_operator(cx); + }) +} + +fn move_cursor(vim: &mut Vim, motion: Motion, times: usize, cx: &mut MutableAppContext) { vim.update_active_editor(cx, |editor, cx| { editor.change_selections(Some(Autoscroll::Fit), cx, |s| { - s.move_cursors_with(|map, cursor, goal| motion.move_point(map, cursor, goal)) + s.move_cursors_with(|map, cursor, goal| motion.move_point(map, cursor, goal, times)) }) }); } @@ -96,7 +127,7 @@ fn insert_after(_: &mut Workspace, _: &InsertAfter, cx: &mut ViewContext) { clipboard_text = Cow::Owned(newline_separated_text); } - let mut new_selections = Vec::new(); + // If the pasted text is a single line, the cursor should be placed after + // the newly pasted text. This is easiest done with an anchor after the + // insertion, and then with a fixup to move the selection back one position. + // However if the pasted text is linewise, the cursor should be placed at the start + // of the new text on the following line. This is easiest done with a manually adjusted + // point. + // This enum lets us represent both cases + enum NewPosition { + Inside(Point), + After(Anchor), + } + let mut new_selections: HashMap = Default::default(); editor.buffer().update(cx, |buffer, cx| { let snapshot = buffer.snapshot(cx); let mut start_offset = 0; @@ -254,8 +296,10 @@ fn paste(_: &mut Workspace, _: &Paste, cx: &mut ViewContext) { edits.push((point..point, "\n")); } // Drop selection at the start of the next line - let selection_point = Point::new(point.row + 1, 0); - new_selections.push(selection.map(|_| selection_point)); + new_selections.insert( + selection.id, + NewPosition::Inside(Point::new(point.row + 1, 0)), + ); point } else { let mut point = selection.end; @@ -265,7 +309,14 @@ fn paste(_: &mut Workspace, _: &Paste, cx: &mut ViewContext) { .clip_point(point, Bias::Right) .to_point(&display_map); - new_selections.push(selection.map(|_| point)); + new_selections.insert( + selection.id, + if to_insert.contains('\n') { + NewPosition::Inside(point) + } else { + NewPosition::After(snapshot.anchor_after(point)) + }, + ); point }; @@ -283,7 +334,25 @@ fn paste(_: &mut Workspace, _: &Paste, cx: &mut ViewContext) { }); editor.change_selections(Some(Autoscroll::Fit), cx, |s| { - s.select(new_selections) + s.move_with(|map, selection| { + if let Some(new_position) = new_selections.get(&selection.id) { + match new_position { + NewPosition::Inside(new_point) => { + selection.collapse_to( + new_point.to_display_point(map), + SelectionGoal::None, + ); + } + NewPosition::After(after_point) => { + let mut new_point = after_point.to_display_point(map); + *new_point.column_mut() = + new_point.column().saturating_sub(1); + new_point = map.clip_point(new_point, Bias::Left); + selection.collapse_to(new_point, SelectionGoal::None); + } + } + } + }); }); } else { editor.insert(&clipboard_text, cx); @@ -298,364 +367,165 @@ fn paste(_: &mut Workspace, _: &Paste, cx: &mut ViewContext) { #[cfg(test)] mod test { use indoc::indoc; - use util::test::marked_text_offsets; use crate::{ state::{ Mode::{self, *}, Namespace, Operator, }, - vim_test_context::VimTestContext, + test::{NeovimBackedTestContext, VimTestContext}, }; #[gpui::test] async fn test_h(cx: &mut gpui::TestAppContext) { - let cx = VimTestContext::new(cx, true).await; - let mut cx = cx.binding(["h"]); - cx.assert("The qˇuick", "The ˇquick"); - cx.assert("ˇThe quick", "ˇThe quick"); - cx.assert( - indoc! {" - The quick - ˇbrown"}, - indoc! {" - The quick - ˇbrown"}, - ); + let mut cx = NeovimBackedTestContext::new(cx).await.binding(["h"]); + cx.assert_all(indoc! {" + ˇThe qˇuick + ˇbrown" + }) + .await; } #[gpui::test] async fn test_backspace(cx: &mut gpui::TestAppContext) { - let cx = VimTestContext::new(cx, true).await; - let mut cx = cx.binding(["backspace"]); - cx.assert("The qˇuick", "The ˇquick"); - cx.assert("ˇThe quick", "ˇThe quick"); - cx.assert( - indoc! {" - The quick - ˇbrown"}, - indoc! {" - The quick - ˇbrown"}, - ); + let mut cx = NeovimBackedTestContext::new(cx) + .await + .binding(["backspace"]); + cx.assert_all(indoc! {" + ˇThe qˇuick + ˇbrown" + }) + .await; } #[gpui::test] async fn test_j(cx: &mut gpui::TestAppContext) { - let cx = VimTestContext::new(cx, true).await; - let mut cx = cx.binding(["j"]); - cx.assert( - indoc! {" - The ˇquick - brown fox"}, - indoc! {" - The quick - browˇn fox"}, - ); - cx.assert( - indoc! {" - The quick - browˇn fox"}, - indoc! {" - The quick - browˇn fox"}, - ); - cx.assert( - indoc! {" - The quicˇk - brown"}, - indoc! {" - The quick - browˇn"}, - ); - cx.assert( - indoc! {" - The quick - ˇbrown"}, - indoc! {" - The quick - ˇbrown"}, - ); + let mut cx = NeovimBackedTestContext::new(cx).await.binding(["j"]); + cx.assert_all(indoc! {" + ˇThe qˇuick broˇwn + ˇfox jumps" + }) + .await; } #[gpui::test] async fn test_k(cx: &mut gpui::TestAppContext) { - let cx = VimTestContext::new(cx, true).await; - let mut cx = cx.binding(["k"]); - cx.assert( - indoc! {" - The ˇquick - brown fox"}, - indoc! {" - The ˇquick - brown fox"}, - ); - cx.assert( - indoc! {" - The quick - browˇn fox"}, - indoc! {" - The ˇquick - brown fox"}, - ); - cx.assert( - indoc! {" - The - quicˇk"}, - indoc! {" - Thˇe - quick"}, - ); + let mut cx = NeovimBackedTestContext::new(cx).await.binding(["k"]); + cx.assert_all(indoc! {" + ˇThe qˇuick + ˇbrown fˇox jumˇps" + }) + .await; } #[gpui::test] async fn test_l(cx: &mut gpui::TestAppContext) { - let cx = VimTestContext::new(cx, true).await; - let mut cx = cx.binding(["l"]); - cx.assert("The qˇuick", "The quˇick"); - cx.assert("The quicˇk", "The quicˇk"); - cx.assert( - indoc! {" - The quicˇk - brown"}, - indoc! {" - The quicˇk - brown"}, - ); + let mut cx = NeovimBackedTestContext::new(cx).await.binding(["l"]); + cx.assert_all(indoc! {" + ˇThe qˇuicˇk + ˇbrowˇn"}) + .await; } #[gpui::test] async fn test_jump_to_line_boundaries(cx: &mut gpui::TestAppContext) { - let cx = VimTestContext::new(cx, true).await; - let mut cx = cx.binding(["$"]); - cx.assert("Tˇest test", "Test tesˇt"); - cx.assert("Test tesˇt", "Test tesˇt"); - cx.assert( + let mut cx = NeovimBackedTestContext::new(cx).await; + cx.assert_binding_matches_all( + ["$"], indoc! {" - The ˇquick - brown"}, + ˇThe qˇuicˇk + ˇbrowˇn"}, + ) + .await; + cx.assert_binding_matches_all( + ["0"], indoc! {" - The quicˇk - brown"}, - ); - cx.assert( - indoc! {" - The quicˇk - brown"}, - indoc! {" - The quicˇk - brown"}, - ); - - let mut cx = cx.binding(["0"]); - cx.assert("Test ˇtest", "ˇTest test"); - cx.assert("ˇTest test", "ˇTest test"); - cx.assert( - indoc! {" - The ˇquick - brown"}, - indoc! {" - ˇThe quick - brown"}, - ); - cx.assert( - indoc! {" - ˇThe quick - brown"}, - indoc! {" - ˇThe quick - brown"}, - ); + ˇThe qˇuicˇk + ˇbrowˇn"}, + ) + .await; } #[gpui::test] async fn test_jump_to_end(cx: &mut gpui::TestAppContext) { - let cx = VimTestContext::new(cx, true).await; - let mut cx = cx.binding(["shift-g"]); + let mut cx = NeovimBackedTestContext::new(cx).await.binding(["shift-g"]); - cx.assert( - indoc! {" + cx.assert_all(indoc! {" The ˇquick brown fox jumps - over the lazy dog"}, - indoc! {" - The quick - - brown fox jumps - overˇ the lazy dog"}, - ); - cx.assert( - indoc! {" - The quick - - brown fox jumps - overˇ the lazy dog"}, - indoc! {" - The quick - - brown fox jumps - overˇ the lazy dog"}, - ); - cx.assert( - indoc! {" + overˇ the lazy doˇg"}) + .await; + cx.assert(indoc! {" The quiˇck - brown"}, - indoc! {" - The quick - - browˇn"}, - ); - cx.assert( - indoc! {" + brown"}) + .await; + cx.assert(indoc! {" The quiˇck - "}, - indoc! {" - The quick - - ˇ"}, - ); + "}) + .await; } #[gpui::test] async fn test_w(cx: &mut gpui::TestAppContext) { - let mut cx = VimTestContext::new(cx, true).await; - let (_, cursor_offsets) = marked_text_offsets(indoc! {" + let mut cx = NeovimBackedTestContext::new(cx).await.binding(["w"]); + cx.assert_all(indoc! {" The ˇquickˇ-ˇbrown ˇ ˇ ˇfox_jumps ˇover - ˇthˇˇe"}); - cx.set_state( - indoc! {" - ˇThe quick-brown - - - fox_jumps over - the"}, - Mode::Normal, - ); - - for cursor_offset in cursor_offsets { - cx.simulate_keystroke("w"); - cx.assert_editor_selections(vec![cursor_offset..cursor_offset]); - } - - // Reset and test ignoring punctuation - let (_, cursor_offsets) = marked_text_offsets(indoc! {" - The ˇquick-brown + ˇthˇe"}) + .await; + let mut cx = cx.binding(["shift-w"]); + cx.assert_all(indoc! {" + The ˇquickˇ-ˇbrown ˇ ˇ ˇfox_jumps ˇover - ˇthˇˇe"}); - cx.set_state( - indoc! {" - ˇThe quick-brown - - - fox_jumps over - the"}, - Mode::Normal, - ); - - for cursor_offset in cursor_offsets { - cx.simulate_keystroke("shift-w"); - cx.assert_editor_selections(vec![cursor_offset..cursor_offset]); - } + ˇthˇe"}) + .await; } #[gpui::test] async fn test_e(cx: &mut gpui::TestAppContext) { - let mut cx = VimTestContext::new(cx, true).await; - let (_, cursor_offsets) = marked_text_offsets(indoc! {" + let mut cx = NeovimBackedTestContext::new(cx).await.binding(["e"]); + cx.assert_all(indoc! {" Thˇe quicˇkˇ-browˇn fox_jumpˇs oveˇr - thˇe"}); - cx.set_state( - indoc! {" - ˇThe quick-brown - - - fox_jumps over - the"}, - Mode::Normal, - ); - - for cursor_offset in cursor_offsets { - cx.simulate_keystroke("e"); - cx.assert_editor_selections(vec![cursor_offset..cursor_offset]); - } - - // Reset and test ignoring punctuation - let (_, cursor_offsets) = marked_text_offsets(indoc! {" - Thˇe quick-browˇn + thˇe"}) + .await; + let mut cx = cx.binding(["shift-e"]); + cx.assert_all(indoc! {" + Thˇe quicˇkˇ-browˇn fox_jumpˇs oveˇr - thˇˇe"}); - cx.set_state( - indoc! {" - ˇThe quick-brown - - - fox_jumps over - the"}, - Mode::Normal, - ); - for cursor_offset in cursor_offsets { - cx.simulate_keystroke("shift-e"); - cx.assert_editor_selections(vec![cursor_offset..cursor_offset]); - } + thˇe"}) + .await; } #[gpui::test] async fn test_b(cx: &mut gpui::TestAppContext) { - let mut cx = VimTestContext::new(cx, true).await; - let (_, cursor_offsets) = marked_text_offsets(indoc! {" - ˇˇThe ˇquickˇ-ˇbrown + let mut cx = NeovimBackedTestContext::new(cx).await.binding(["b"]); + cx.assert_all(indoc! {" + ˇThe ˇquickˇ-ˇbrown ˇ ˇ ˇfox_jumps ˇover - ˇthe"}); - cx.set_state( - indoc! {" - The quick-brown - - - fox_jumps over - thˇe"}, - Mode::Normal, - ); - - for cursor_offset in cursor_offsets.into_iter().rev() { - cx.simulate_keystroke("b"); - cx.assert_editor_selections(vec![cursor_offset..cursor_offset]); - } - - // Reset and test ignoring punctuation - let (_, cursor_offsets) = marked_text_offsets(indoc! {" - ˇˇThe ˇquick-brown + ˇthe"}) + .await; + let mut cx = cx.binding(["shift-b"]); + cx.assert_all(indoc! {" + ˇThe ˇquickˇ-ˇbrown ˇ ˇ ˇfox_jumps ˇover - ˇthe"}); - cx.set_state( - indoc! {" - The quick-brown - - - fox_jumps over - thˇe"}, - Mode::Normal, - ); - for cursor_offset in cursor_offsets.into_iter().rev() { - cx.simulate_keystroke("shift-b"); - cx.assert_editor_selections(vec![cursor_offset..cursor_offset]); - } + ˇthe"}) + .await; } #[gpui::test] @@ -676,513 +546,271 @@ mod test { #[gpui::test] async fn test_gg(cx: &mut gpui::TestAppContext) { - let cx = VimTestContext::new(cx, true).await; - let mut cx = cx.binding(["g", "g"]); - cx.assert( - indoc! {" - The quick - - brown fox jumps - over ˇthe lazy dog"}, + let mut cx = NeovimBackedTestContext::new(cx).await; + cx.assert_binding_matches_all( + ["g", "g"], indoc! {" The qˇuick brown fox jumps - over the lazy dog"}, - ); - cx.assert( - indoc! {" - The qˇuick - - brown fox jumps - over the lazy dog"}, - indoc! {" - The qˇuick - - brown fox jumps - over the lazy dog"}, - ); - cx.assert( - indoc! {" - The quick - - brown fox jumps - over the laˇzy dog"}, - indoc! {" - The quicˇk - - brown fox jumps - over the lazy dog"}, - ); - cx.assert( + over ˇthe laˇzy dog"}, + ) + .await; + cx.assert_binding_matches( + ["g", "g"], indoc! {" brown fox jumps over the laˇzy dog"}, + ) + .await; + cx.assert_binding_matches( + ["2", "g", "g"], indoc! {" - ˇ - - brown fox jumps - over the lazy dog"}, - ); + + + brown fox juˇmps + over the lazydog"}, + ) + .await; } #[gpui::test] async fn test_a(cx: &mut gpui::TestAppContext) { - let cx = VimTestContext::new(cx, true).await; - let mut cx = cx.binding(["a"]).mode_after(Mode::Insert); - - cx.assert("The qˇuick", "The quˇick"); - cx.assert("The quicˇk", "The quickˇ"); + let mut cx = NeovimBackedTestContext::new(cx).await.binding(["a"]); + cx.assert_all("The qˇuicˇk").await; } #[gpui::test] async fn test_insert_end_of_line(cx: &mut gpui::TestAppContext) { - let cx = VimTestContext::new(cx, true).await; - let mut cx = cx.binding(["shift-a"]).mode_after(Mode::Insert); - cx.assert("The qˇuick", "The quickˇ"); - cx.assert("The qˇuick ", "The quick ˇ"); - cx.assert("ˇ", "ˇ"); - cx.assert( - indoc! {" - The qˇuick - brown fox"}, - indoc! {" - The quickˇ - brown fox"}, - ); - cx.assert( - indoc! {" - ˇ - The quick"}, - indoc! {" - ˇ - The quick"}, - ); + let mut cx = NeovimBackedTestContext::new(cx).await.binding(["shift-a"]); + cx.assert_all(indoc! {" + ˇ + The qˇuick + brown ˇfox "}) + .await; } #[gpui::test] async fn test_jump_to_first_non_whitespace(cx: &mut gpui::TestAppContext) { - let cx = VimTestContext::new(cx, true).await; - let mut cx = cx.binding(["^"]); - cx.assert("The qˇuick", "ˇThe quick"); - cx.assert(" The qˇuick", " ˇThe quick"); - cx.assert("ˇ", "ˇ"); - cx.assert( - indoc! {" + let mut cx = NeovimBackedTestContext::new(cx).await.binding(["^"]); + cx.assert("The qˇuick").await; + cx.assert(" The qˇuick").await; + cx.assert("ˇ").await; + cx.assert(indoc! {" The qˇuick - brown fox"}, - indoc! {" - ˇThe quick - brown fox"}, - ); - cx.assert( - indoc! {" + brown fox"}) + .await; + cx.assert(indoc! {" ˇ - The quick"}, - indoc! {" - ˇ - The quick"}, - ); + The quick"}) + .await; // Indoc disallows trailing whitspace. - cx.assert(" ˇ \nThe quick", " ˇ \nThe quick"); + cx.assert(" ˇ \nThe quick").await; } #[gpui::test] async fn test_insert_first_non_whitespace(cx: &mut gpui::TestAppContext) { - let cx = VimTestContext::new(cx, true).await; - let mut cx = cx.binding(["shift-i"]).mode_after(Mode::Insert); - cx.assert("The qˇuick", "ˇThe quick"); - cx.assert(" The qˇuick", " ˇThe quick"); - cx.assert("ˇ", "ˇ"); - cx.assert( - indoc! {" + let mut cx = NeovimBackedTestContext::new(cx).await.binding(["shift-i"]); + cx.assert("The qˇuick").await; + cx.assert(" The qˇuick").await; + cx.assert("ˇ").await; + cx.assert(indoc! {" The qˇuick - brown fox"}, - indoc! {" - ˇThe quick - brown fox"}, - ); - cx.assert( - indoc! {" + brown fox"}) + .await; + cx.assert(indoc! {" ˇ - The quick"}, - indoc! {" - ˇ - The quick"}, - ); + The quick"}) + .await; } #[gpui::test] async fn test_delete_to_end_of_line(cx: &mut gpui::TestAppContext) { - let cx = VimTestContext::new(cx, true).await; - let mut cx = cx.binding(["shift-d"]); - cx.assert( - indoc! {" + let mut cx = NeovimBackedTestContext::new(cx).await.binding(["shift-d"]); + cx.assert(indoc! {" The qˇuick - brown fox"}, - indoc! {" - The ˇq - brown fox"}, - ); - cx.assert( - indoc! {" + brown fox"}) + .await; + cx.assert(indoc! {" The quick ˇ - brown fox"}, - indoc! {" - The quick - ˇ - brown fox"}, - ); + brown fox"}) + .await; } #[gpui::test] async fn test_x(cx: &mut gpui::TestAppContext) { - let cx = VimTestContext::new(cx, true).await; - let mut cx = cx.binding(["x"]); - cx.assert("ˇTest", "ˇest"); - cx.assert("Teˇst", "Teˇt"); - cx.assert("Tesˇt", "Teˇs"); - cx.assert( - indoc! {" + let mut cx = NeovimBackedTestContext::new(cx).await.binding(["x"]); + cx.assert_all("ˇTeˇsˇt").await; + cx.assert(indoc! {" Tesˇt - test"}, - indoc! {" - Teˇs - test"}, - ); + test"}) + .await; } #[gpui::test] async fn test_delete_left(cx: &mut gpui::TestAppContext) { - let cx = VimTestContext::new(cx, true).await; - let mut cx = cx.binding(["shift-x"]); - cx.assert("Teˇst", "Tˇst"); - cx.assert("Tˇest", "ˇest"); - cx.assert("ˇTest", "ˇTest"); - cx.assert( - indoc! {" + let mut cx = NeovimBackedTestContext::new(cx).await.binding(["shift-x"]); + cx.assert_all("ˇTˇeˇsˇt").await; + cx.assert(indoc! {" Test - ˇtest"}, - indoc! {" - Test - ˇtest"}, - ); + ˇtest"}) + .await; } #[gpui::test] async fn test_o(cx: &mut gpui::TestAppContext) { - let cx = VimTestContext::new(cx, true).await; - let mut cx = cx.binding(["o"]).mode_after(Mode::Insert); - - cx.assert( - "ˇ", - indoc! {" - - ˇ"}, - ); - cx.assert( - "The ˇquick", - indoc! {" - The quick - ˇ"}, - ); - cx.assert( - indoc! {" - The quick - brown ˇfox - jumps over"}, - indoc! {" - The quick - brown fox - ˇ - jumps over"}, - ); - cx.assert( - indoc! {" - The quick - brown fox - jumps ˇover"}, - indoc! {" - The quick - brown fox - jumps over - ˇ"}, - ); - cx.assert( - indoc! {" + let mut cx = NeovimBackedTestContext::new(cx).await.binding(["o"]); + cx.assert("ˇ").await; + cx.assert("The ˇquick").await; + cx.assert_all(indoc! {" The qˇuick - brown fox - jumps over"}, - indoc! {" + brown ˇfox + jumps ˇover"}) + .await; + cx.assert(indoc! {" The quick ˇ - brown fox - jumps over"}, - ); - cx.assert( - indoc! {" - The quick - ˇ - brown fox"}, - indoc! {" - The quick - - ˇ - brown fox"}, - ); - cx.assert( - indoc! {" + brown fox"}) + .await; + cx.assert(indoc! {" fn test() { println!(ˇ); } - "}, - indoc! {" - fn test() { - println!(); - ˇ - } - "}, - ); - cx.assert( - indoc! {" + "}) + .await; + cx.assert(indoc! {" fn test(ˇ) { println!(); - }"}, - indoc! {" - fn test() { - ˇ - println!(); - }"}, - ); + }"}) + .await; } #[gpui::test] async fn test_insert_line_above(cx: &mut gpui::TestAppContext) { - let cx = VimTestContext::new(cx, true).await; - let mut cx = cx.binding(["shift-o"]).mode_after(Mode::Insert); + let cx = NeovimBackedTestContext::new(cx).await; + let mut cx = cx.binding(["shift-o"]); + cx.assert("ˇ").await; + cx.assert("The ˇquick").await; + cx.assert_all(indoc! {" + The qˇuick + brown ˇfox + jumps ˇover"}) + .await; + cx.assert(indoc! {" + The quick + ˇ + brown fox"}) + .await; - cx.assert( - "ˇ", - indoc! {" - ˇ - "}, - ); - cx.assert( - "The ˇquick", - indoc! {" - ˇ - The quick"}, - ); - cx.assert( - indoc! {" - The quick - brown ˇfox - jumps over"}, - indoc! {" - The quick - ˇ - brown fox - jumps over"}, - ); - cx.assert( - indoc! {" - The quick - brown fox - jumps ˇover"}, - indoc! {" - The quick - brown fox - ˇ - jumps over"}, - ); - cx.assert( - indoc! {" - The qˇuick - brown fox - jumps over"}, - indoc! {" - ˇ - The quick - brown fox - jumps over"}, - ); - cx.assert( - indoc! {" - The quick - ˇ - brown fox"}, - indoc! {" - The quick - ˇ - - brown fox"}, - ); - cx.assert( + // Our indentation is smarter than vims. So we don't match here + cx.assert_manual( indoc! {" fn test() println!(ˇ);"}, + Mode::Normal, indoc! {" fn test() ˇ println!();"}, + Mode::Insert, ); - cx.assert( + cx.assert_manual( indoc! {" fn test(ˇ) { println!(); }"}, + Mode::Normal, indoc! {" ˇ fn test() { println!(); }"}, + Mode::Insert, ); } #[gpui::test] async fn test_dd(cx: &mut gpui::TestAppContext) { - let cx = VimTestContext::new(cx, true).await; - let mut cx = cx.binding(["d", "d"]); - - cx.assert("ˇ", "ˇ"); - cx.assert("The ˇquick", "ˇ"); - cx.assert( - indoc! {" - The quick - brown ˇfox - jumps over"}, - indoc! {" - The quick - jumps ˇover"}, - ); - cx.assert( - indoc! {" - The quick - brown fox - jumps ˇover"}, - indoc! {" - The quick - brown ˇfox"}, - ); - cx.assert( - indoc! {" + let mut cx = NeovimBackedTestContext::new(cx).await.binding(["d", "d"]); + cx.assert("ˇ").await; + cx.assert("The ˇquick").await; + cx.assert_all(indoc! {" The qˇuick - brown fox - jumps over"}, - indoc! {" - brownˇ fox - jumps over"}, - ); - cx.assert( - indoc! {" + brown ˇfox + jumps ˇover"}) + .await; + cx.assert(indoc! {" The quick ˇ - brown fox"}, - indoc! {" - The quick - ˇbrown fox"}, - ); + brown fox"}) + .await; } #[gpui::test] async fn test_cc(cx: &mut gpui::TestAppContext) { - let cx = VimTestContext::new(cx, true).await; - let mut cx = cx.binding(["c", "c"]).mode_after(Mode::Insert); - - cx.assert("ˇ", "ˇ"); - cx.assert("The ˇquick", "ˇ"); - cx.assert( - indoc! {" - The quick + let mut cx = NeovimBackedTestContext::new(cx).await.binding(["c", "c"]); + cx.assert("ˇ").await; + cx.assert("The ˇquick").await; + cx.assert_all(indoc! {" + The quˇick brown ˇfox - jumps over"}, - indoc! {" + jumps ˇover"}) + .await; + cx.assert(indoc! {" The quick ˇ - jumps over"}, - ); - cx.assert( - indoc! {" - The quick - brown fox - jumps ˇover"}, - indoc! {" - The quick - brown fox - ˇ"}, - ); - cx.assert( - indoc! {" - The qˇuick - brown fox - jumps over"}, - indoc! {" - ˇ - brown fox - jumps over"}, - ); - cx.assert( - indoc! {" - The quick - ˇ - brown fox"}, - indoc! {" - The quick - ˇ - brown fox"}, - ); + brown fox"}) + .await; } #[gpui::test] async fn test_p(cx: &mut gpui::TestAppContext) { - let mut cx = VimTestContext::new(cx, true).await; - cx.set_state( - indoc! {" + let mut cx = NeovimBackedTestContext::new(cx).await; + cx.set_shared_state(indoc! {" The quick brown fox juˇmps over - the lazy dog"}, - Mode::Normal, - ); + the lazy dog"}) + .await; - cx.simulate_keystrokes(["d", "d"]); - cx.assert_editor_state(indoc! {" - The quick brown - the laˇzy dog"}); + cx.simulate_shared_keystrokes(["d", "d"]).await; + cx.assert_state_matches().await; - cx.simulate_keystroke("p"); - cx.assert_state( - indoc! {" + cx.simulate_shared_keystroke("p").await; + cx.assert_state_matches().await; + + cx.set_shared_state(indoc! {" The quick brown - the lazy dog - ˇfox jumps over"}, - Mode::Normal, - ); - - cx.set_state( - indoc! {" - The quick brown - fox «jumpˇ»s over - the lazy dog"}, - Mode::Visual { line: false }, - ); - cx.simulate_keystroke("y"); - cx.set_state( - indoc! {" + fox ˇjumps over + the lazy dog"}) + .await; + cx.simulate_shared_keystrokes(["v", "w", "y"]).await; + cx.set_shared_state(indoc! {" The quick brown fox jumps oveˇr - the lazy dog"}, - Mode::Normal, - ); - cx.simulate_keystroke("p"); - cx.assert_state( - indoc! {" - The quick brown - fox jumps overˇjumps - the lazy dog"}, - Mode::Normal, - ); + the lazy dog"}) + .await; + cx.simulate_shared_keystroke("p").await; + cx.assert_state_matches().await; + } + + #[gpui::test] + async fn test_repeated_word(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + for count in 1..=5 { + cx.assert_binding_matches_all( + [&count.to_string(), "w"], + indoc! {" + ˇThe quˇickˇ browˇn + ˇ + ˇfox ˇjumpsˇ-ˇoˇver + ˇthe lazy dog + "}, + ) + .await; + } } } diff --git a/crates/vim/src/normal/change.rs b/crates/vim/src/normal/change.rs index 8695c9668b..ee83c3490d 100644 --- a/crates/vim/src/normal/change.rs +++ b/crates/vim/src/normal/change.rs @@ -1,30 +1,20 @@ -use crate::{motion::Motion, state::Mode, utils::copy_selections_content, Vim}; -use editor::{char_kind, movement, Autoscroll}; -use gpui::{impl_actions, MutableAppContext, ViewContext}; -use serde::Deserialize; -use workspace::Workspace; +use crate::{motion::Motion, object::Object, state::Mode, utils::copy_selections_content, Vim}; +use editor::{char_kind, display_map::DisplaySnapshot, movement, Autoscroll, DisplayPoint}; +use gpui::MutableAppContext; +use language::Selection; -#[derive(Clone, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -struct ChangeWord { - #[serde(default)] - ignore_punctuation: bool, -} - -impl_actions!(vim, [ChangeWord]); - -pub fn init(cx: &mut MutableAppContext) { - cx.add_action(change_word); -} - -pub fn change_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) { +pub fn change_motion(vim: &mut Vim, motion: Motion, times: usize, cx: &mut MutableAppContext) { vim.update_active_editor(cx, |editor, cx| { editor.transact(cx, |editor, cx| { // We are swapping to insert mode anyway. Just set the line end clipping behavior now editor.set_clip_at_line_ends(false, cx); editor.change_selections(Some(Autoscroll::Fit), cx, |s| { s.move_with(|map, selection| { - motion.expand_selection(map, selection, false); + if let Motion::NextWordStart { ignore_punctuation } = motion { + expand_changed_word_selection(map, selection, times, ignore_punctuation); + } else { + motion.expand_selection(map, selection, times, false); + } }); }); copy_selections_content(editor, motion.linewise(), cx); @@ -34,43 +24,60 @@ pub fn change_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) { vim.switch_mode(Mode::Insert, false, cx) } +pub fn change_object(vim: &mut Vim, object: Object, around: bool, cx: &mut MutableAppContext) { + let mut objects_found = false; + vim.update_active_editor(cx, |editor, cx| { + // We are swapping to insert mode anyway. Just set the line end clipping behavior now + editor.set_clip_at_line_ends(false, cx); + editor.transact(cx, |editor, cx| { + editor.change_selections(Some(Autoscroll::Fit), cx, |s| { + s.move_with(|map, selection| { + objects_found |= object.expand_selection(map, selection, around); + }); + }); + if objects_found { + copy_selections_content(editor, false, cx); + editor.insert("", cx); + } + }); + }); + + if objects_found { + vim.switch_mode(Mode::Insert, false, cx); + } else { + vim.switch_mode(Mode::Normal, false, cx); + } +} + // From the docs https://vimhelp.org/change.txt.html#cw // Special case: When the cursor is in a word, "cw" and "cW" do not include the // white space after a word, they only change up to the end of the word. This is // because Vim interprets "cw" as change-word, and a word does not include the // following white space. -fn change_word( - _: &mut Workspace, - &ChangeWord { ignore_punctuation }: &ChangeWord, - cx: &mut ViewContext, +fn expand_changed_word_selection( + map: &DisplaySnapshot, + selection: &mut Selection, + times: usize, + ignore_punctuation: bool, ) { - Vim::update(cx, |vim, cx| { - vim.update_active_editor(cx, |editor, cx| { - editor.transact(cx, |editor, cx| { - // We are swapping to insert mode anyway. Just set the line end clipping behavior now - editor.set_clip_at_line_ends(false, cx); - editor.change_selections(Some(Autoscroll::Fit), cx, |s| { - s.move_with(|map, selection| { - if selection.end.column() == map.line_len(selection.end.row()) { - return; - } + if times > 1 { + Motion::NextWordStart { ignore_punctuation }.expand_selection( + map, + selection, + times - 1, + false, + ); + } - selection.end = - movement::find_boundary(map, selection.end, |left, right| { - let left_kind = - char_kind(left).coerce_punctuation(ignore_punctuation); - let right_kind = - char_kind(right).coerce_punctuation(ignore_punctuation); + if times == 1 && selection.end.column() == map.line_len(selection.end.row()) { + return; + } - left_kind != right_kind || left == '\n' || right == '\n' - }); - }); - }); - copy_selections_content(editor, false, cx); - editor.insert("", cx); - }); - }); - vim.switch_mode(Mode::Insert, false, cx); + selection.end = movement::find_boundary(map, selection.end, |left, right| { + let left_kind = char_kind(left).coerce_punctuation(ignore_punctuation); + let right_kind = char_kind(right).coerce_punctuation(ignore_punctuation); + + left_kind != right_kind || left == '\n' || right == '\n' }); } @@ -78,7 +85,10 @@ fn change_word( mod test { use indoc::indoc; - use crate::{state::Mode, vim_test_context::VimTestContext}; + use crate::{ + state::Mode, + test::{NeovimBackedTestContext, VimTestContext}, + }; #[gpui::test] async fn test_change_h(cx: &mut gpui::TestAppContext) { @@ -170,8 +180,7 @@ mod test { test"}, indoc! {" Test test - ˇ - test"}, + ˇ"}, ); let mut cx = cx.binding(["c", "shift-e"]); @@ -193,6 +202,7 @@ mod test { Test ˇ test"}, ); + println!("Marker"); cx.assert( indoc! {" Test test @@ -442,4 +452,85 @@ mod test { the lazy"}, ); } + + #[gpui::test] + async fn test_repeated_cj(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + for count in 1..=5 { + cx.assert_binding_matches_all( + ["c", &count.to_string(), "j"], + indoc! {" + ˇThe quˇickˇ browˇn + ˇ + ˇfox ˇjumpsˇ-ˇoˇver + ˇthe lazy dog + "}, + ) + .await; + } + } + + #[gpui::test] + async fn test_repeated_cl(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + for count in 1..=5 { + cx.assert_binding_matches_all( + ["c", &count.to_string(), "l"], + indoc! {" + ˇThe quˇickˇ browˇn + ˇ + ˇfox ˇjumpsˇ-ˇoˇver + ˇthe lazy dog + "}, + ) + .await; + } + } + + #[gpui::test] + async fn test_repeated_cb(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + // Changing back any number of times from the start of the file doesn't + // switch to insert mode in vim. This is weird and painful to implement + cx.add_initial_state_exemption(indoc! {" + ˇThe quick brown + + fox jumps-over + the lazy dog + "}); + + for count in 1..=5 { + cx.assert_binding_matches_all( + ["c", &count.to_string(), "b"], + indoc! {" + ˇThe quˇickˇ browˇn + ˇ + ˇfox ˇjumpsˇ-ˇoˇver + ˇthe lazy dog + "}, + ) + .await; + } + } + + #[gpui::test] + async fn test_repeated_ce(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + for count in 1..=5 { + cx.assert_binding_matches_all( + ["c", &count.to_string(), "e"], + indoc! {" + ˇThe quˇickˇ browˇn + ˇ + ˇfox ˇjumpsˇ-ˇoˇver + ˇthe lazy dog + "}, + ) + .await; + } + } } diff --git a/crates/vim/src/normal/delete.rs b/crates/vim/src/normal/delete.rs index b2e228bdb1..a2c540a59c 100644 --- a/crates/vim/src/normal/delete.rs +++ b/crates/vim/src/normal/delete.rs @@ -1,9 +1,9 @@ -use crate::{motion::Motion, utils::copy_selections_content, Vim}; -use collections::HashMap; -use editor::{Autoscroll, Bias}; +use crate::{motion::Motion, object::Object, utils::copy_selections_content, Vim}; +use collections::{HashMap, HashSet}; +use editor::{display_map::ToDisplayPoint, Autoscroll, Bias}; use gpui::MutableAppContext; -pub fn delete_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) { +pub fn delete_motion(vim: &mut Vim, motion: Motion, times: usize, cx: &mut MutableAppContext) { vim.update_active_editor(cx, |editor, cx| { editor.transact(cx, |editor, cx| { editor.set_clip_at_line_ends(false, cx); @@ -11,8 +11,8 @@ pub fn delete_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) { editor.change_selections(Some(Autoscroll::Fit), cx, |s| { s.move_with(|map, selection| { let original_head = selection.head(); - motion.expand_selection(map, selection, true); original_columns.insert(selection.id, original_head.column()); + motion.expand_selection(map, selection, times, true); }); }); copy_selections_content(editor, motion.linewise(), cx); @@ -36,11 +36,67 @@ pub fn delete_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) { }); } +pub fn delete_object(vim: &mut Vim, object: Object, around: bool, cx: &mut MutableAppContext) { + vim.update_active_editor(cx, |editor, cx| { + editor.transact(cx, |editor, cx| { + editor.set_clip_at_line_ends(false, cx); + // Emulates behavior in vim where if we expanded backwards to include a newline + // the cursor gets set back to the start of the line + let mut should_move_to_start: HashSet<_> = Default::default(); + editor.change_selections(Some(Autoscroll::Fit), cx, |s| { + s.move_with(|map, selection| { + object.expand_selection(map, selection, around); + let offset_range = selection.map(|p| p.to_offset(map, Bias::Left)).range(); + let contains_only_newlines = map + .chars_at(selection.start) + .take_while(|(_, p)| p < &selection.end) + .all(|(char, _)| char == '\n') + && !offset_range.is_empty(); + let end_at_newline = map + .chars_at(selection.end) + .next() + .map(|(c, _)| c == '\n') + .unwrap_or(false); + + // If expanded range contains only newlines and + // the object is around or sentence, expand to include a newline + // at the end or start + if (around || object == Object::Sentence) && contains_only_newlines { + if end_at_newline { + selection.end = + (offset_range.end + '\n'.len_utf8()).to_display_point(map); + } else if selection.start.row() > 0 { + should_move_to_start.insert(selection.id); + selection.start = + (offset_range.start - '\n'.len_utf8()).to_display_point(map); + } + } + }); + }); + copy_selections_content(editor, false, cx); + editor.insert("", cx); + + // Fixup cursor position after the deletion + editor.set_clip_at_line_ends(true, cx); + editor.change_selections(Some(Autoscroll::Fit), cx, |s| { + s.move_with(|map, selection| { + let mut cursor = selection.head(); + if should_move_to_start.contains(&selection.id) { + *cursor.column_mut() = 0; + } + cursor = map.clip_point(cursor, Bias::Left); + selection.collapse_to(cursor, selection.goal) + }); + }); + }); + }); +} + #[cfg(test)] mod test { use indoc::indoc; - use crate::{state::Mode, vim_test_context::VimTestContext}; + use crate::{state::Mode, test::VimTestContext}; #[gpui::test] async fn test_delete_h(cx: &mut gpui::TestAppContext) { @@ -140,8 +196,7 @@ mod test { test"}, indoc! {" Test test - ˇ - test"}, + ˇ"}, ); let mut cx = cx.binding(["d", "shift-e"]); diff --git a/crates/vim/src/normal/yank.rs b/crates/vim/src/normal/yank.rs index 17a9e47d3d..e7d6b3076b 100644 --- a/crates/vim/src/normal/yank.rs +++ b/crates/vim/src/normal/yank.rs @@ -1,8 +1,8 @@ -use crate::{motion::Motion, utils::copy_selections_content, Vim}; +use crate::{motion::Motion, object::Object, utils::copy_selections_content, Vim}; use collections::HashMap; use gpui::MutableAppContext; -pub fn yank_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) { +pub fn yank_motion(vim: &mut Vim, motion: Motion, times: usize, cx: &mut MutableAppContext) { vim.update_active_editor(cx, |editor, cx| { editor.transact(cx, |editor, cx| { editor.set_clip_at_line_ends(false, cx); @@ -10,8 +10,8 @@ pub fn yank_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) { 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); + motion.expand_selection(map, selection, times, true); }); }); copy_selections_content(editor, motion.linewise(), cx); @@ -24,3 +24,26 @@ pub fn yank_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) { }); }); } + +pub fn yank_object(vim: &mut Vim, object: Object, around: bool, 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); + object.expand_selection(map, selection, around); + original_positions.insert(selection.id, original_position); + }); + }); + copy_selections_content(editor, false, 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); + }); + }); + }); + }); +} diff --git a/crates/vim/src/object.rs b/crates/vim/src/object.rs new file mode 100644 index 0000000000..b39dc6790b --- /dev/null +++ b/crates/vim/src/object.rs @@ -0,0 +1,640 @@ +use std::ops::Range; + +use editor::{char_kind, display_map::DisplaySnapshot, movement, Bias, CharKind, DisplayPoint}; +use gpui::{actions, impl_actions, MutableAppContext}; +use language::Selection; +use serde::Deserialize; +use workspace::Workspace; + +use crate::{motion::right, normal::normal_object, state::Mode, visual::visual_object, Vim}; + +#[derive(Copy, Clone, Debug, PartialEq)] +pub enum Object { + Word { ignore_punctuation: bool }, + Sentence, + Quotes, + BackQuotes, + DoubleQuotes, + Parentheses, + SquareBrackets, + CurlyBrackets, + AngleBrackets, +} + +#[derive(Clone, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +struct Word { + #[serde(default)] + ignore_punctuation: bool, +} + +actions!( + vim, + [ + Sentence, + Quotes, + BackQuotes, + DoubleQuotes, + Parentheses, + SquareBrackets, + CurlyBrackets, + AngleBrackets + ] +); +impl_actions!(vim, [Word]); + +pub fn init(cx: &mut MutableAppContext) { + cx.add_action( + |_: &mut Workspace, &Word { ignore_punctuation }: &Word, cx: _| { + object(Object::Word { ignore_punctuation }, cx) + }, + ); + cx.add_action(|_: &mut Workspace, _: &Sentence, cx: _| object(Object::Sentence, cx)); + cx.add_action(|_: &mut Workspace, _: &Quotes, cx: _| object(Object::Quotes, cx)); + cx.add_action(|_: &mut Workspace, _: &BackQuotes, cx: _| object(Object::BackQuotes, cx)); + cx.add_action(|_: &mut Workspace, _: &DoubleQuotes, cx: _| object(Object::DoubleQuotes, cx)); + cx.add_action(|_: &mut Workspace, _: &Parentheses, cx: _| object(Object::Parentheses, cx)); + cx.add_action(|_: &mut Workspace, _: &SquareBrackets, cx: _| { + object(Object::SquareBrackets, cx) + }); + cx.add_action(|_: &mut Workspace, _: &CurlyBrackets, cx: _| object(Object::CurlyBrackets, cx)); + cx.add_action(|_: &mut Workspace, _: &AngleBrackets, cx: _| object(Object::AngleBrackets, cx)); +} + +fn object(object: Object, cx: &mut MutableAppContext) { + match Vim::read(cx).state.mode { + Mode::Normal => normal_object(object, cx), + Mode::Visual { .. } => visual_object(object, cx), + Mode::Insert => { + // Shouldn't execute a text object in insert mode. Ignoring + } + } +} + +impl Object { + pub fn range( + self, + map: &DisplaySnapshot, + relative_to: DisplayPoint, + around: bool, + ) -> Option> { + match self { + Object::Word { ignore_punctuation } => { + if around { + around_word(map, relative_to, ignore_punctuation) + } else { + in_word(map, relative_to, ignore_punctuation) + } + } + Object::Sentence => sentence(map, relative_to, around), + Object::Quotes => surrounding_markers(map, relative_to, around, false, '\'', '\''), + Object::BackQuotes => surrounding_markers(map, relative_to, around, false, '`', '`'), + Object::DoubleQuotes => surrounding_markers(map, relative_to, around, false, '"', '"'), + Object::Parentheses => surrounding_markers(map, relative_to, around, true, '(', ')'), + Object::SquareBrackets => surrounding_markers(map, relative_to, around, true, '[', ']'), + Object::CurlyBrackets => surrounding_markers(map, relative_to, around, true, '{', '}'), + Object::AngleBrackets => surrounding_markers(map, relative_to, around, true, '<', '>'), + } + } + + pub fn expand_selection( + self, + map: &DisplaySnapshot, + selection: &mut Selection, + around: bool, + ) -> bool { + if let Some(range) = self.range(map, selection.head(), around) { + selection.start = range.start; + selection.end = range.end; + true + } else { + false + } + } +} + +/// Return a range that surrounds the word relative_to is in +/// If relative_to is at the start of a word, return the word. +/// If relative_to is between words, return the space between +fn in_word( + map: &DisplaySnapshot, + relative_to: DisplayPoint, + ignore_punctuation: bool, +) -> Option> { + // Use motion::right so that we consider the character under the cursor when looking for the start + let start = movement::find_preceding_boundary_in_line( + map, + right(map, relative_to, 1), + |left, right| { + char_kind(left).coerce_punctuation(ignore_punctuation) + != char_kind(right).coerce_punctuation(ignore_punctuation) + }, + ); + let end = movement::find_boundary_in_line(map, relative_to, |left, right| { + char_kind(left).coerce_punctuation(ignore_punctuation) + != char_kind(right).coerce_punctuation(ignore_punctuation) + }); + + Some(start..end) +} + +/// Return a range that surrounds the word and following whitespace +/// relative_to is in. +/// If relative_to is at the start of a word, return the word and following whitespace. +/// If relative_to is between words, return the whitespace back and the following word + +/// if in word +/// delete that word +/// if there is whitespace following the word, delete that as well +/// otherwise, delete any preceding whitespace +/// otherwise +/// delete whitespace around cursor +/// delete word following the cursor +fn around_word( + map: &DisplaySnapshot, + relative_to: DisplayPoint, + ignore_punctuation: bool, +) -> Option> { + let in_word = map + .chars_at(relative_to) + .next() + .map(|(c, _)| char_kind(c) != CharKind::Whitespace) + .unwrap_or(false); + + if in_word { + around_containing_word(map, relative_to, ignore_punctuation) + } else { + around_next_word(map, relative_to, ignore_punctuation) + } +} + +fn around_containing_word( + map: &DisplaySnapshot, + relative_to: DisplayPoint, + ignore_punctuation: bool, +) -> Option> { + in_word(map, relative_to, ignore_punctuation) + .map(|range| expand_to_include_whitespace(map, range, true)) +} + +fn around_next_word( + map: &DisplaySnapshot, + relative_to: DisplayPoint, + ignore_punctuation: bool, +) -> Option> { + // Get the start of the word + let start = movement::find_preceding_boundary_in_line( + map, + right(map, relative_to, 1), + |left, right| { + char_kind(left).coerce_punctuation(ignore_punctuation) + != char_kind(right).coerce_punctuation(ignore_punctuation) + }, + ); + + let mut word_found = false; + let end = movement::find_boundary(map, relative_to, |left, right| { + let left_kind = char_kind(left).coerce_punctuation(ignore_punctuation); + let right_kind = char_kind(right).coerce_punctuation(ignore_punctuation); + + let found = (word_found && left_kind != right_kind) || right == '\n' && left == '\n'; + + if right_kind != CharKind::Whitespace { + word_found = true; + } + + found + }); + + Some(start..end) +} + +fn sentence( + map: &DisplaySnapshot, + relative_to: DisplayPoint, + around: bool, +) -> Option> { + let mut start = None; + let mut previous_end = relative_to; + + let mut chars = map.chars_at(relative_to).peekable(); + + // Search backwards for the previous sentence end or current sentence start. Include the character under relative_to + for (char, point) in chars + .peek() + .cloned() + .into_iter() + .chain(map.reverse_chars_at(relative_to)) + { + if is_sentence_end(map, point) { + break; + } + + if is_possible_sentence_start(char) { + start = Some(point); + } + + previous_end = point; + } + + // Search forward for the end of the current sentence or if we are between sentences, the start of the next one + let mut end = relative_to; + for (char, point) in chars { + if start.is_none() && is_possible_sentence_start(char) { + if around { + start = Some(point); + continue; + } else { + end = point; + break; + } + } + + end = point; + *end.column_mut() += char.len_utf8() as u32; + end = map.clip_point(end, Bias::Left); + + if is_sentence_end(map, end) { + break; + } + } + + let mut range = start.unwrap_or(previous_end)..end; + if around { + range = expand_to_include_whitespace(map, range, false); + } + + Some(range) +} + +fn is_possible_sentence_start(character: char) -> bool { + !character.is_whitespace() && character != '.' +} + +const SENTENCE_END_PUNCTUATION: &[char] = &['.', '!', '?']; +const SENTENCE_END_FILLERS: &[char] = &[')', ']', '"', '\'']; +const SENTENCE_END_WHITESPACE: &[char] = &[' ', '\t', '\n']; +fn is_sentence_end(map: &DisplaySnapshot, point: DisplayPoint) -> bool { + let mut next_chars = map.chars_at(point).peekable(); + if let Some((char, _)) = next_chars.next() { + // We are at a double newline. This position is a sentence end. + if char == '\n' && next_chars.peek().map(|(c, _)| c == &'\n').unwrap_or(false) { + return true; + } + + // The next text is not a valid whitespace. This is not a sentence end + if !SENTENCE_END_WHITESPACE.contains(&char) { + return false; + } + } + + for (char, _) in map.reverse_chars_at(point) { + if SENTENCE_END_PUNCTUATION.contains(&char) { + return true; + } + + if !SENTENCE_END_FILLERS.contains(&char) { + return false; + } + } + + return false; +} + +/// Expands the passed range to include whitespace on one side or the other in a line. Attempts to add the +/// whitespace to the end first and falls back to the start if there was none. +fn expand_to_include_whitespace( + map: &DisplaySnapshot, + mut range: Range, + stop_at_newline: bool, +) -> Range { + let mut whitespace_included = false; + + let mut chars = map.chars_at(range.end).peekable(); + while let Some((char, point)) = chars.next() { + if char == '\n' && stop_at_newline { + break; + } + + if char.is_whitespace() { + // Set end to the next display_point or the character position after the current display_point + range.end = chars.peek().map(|(_, point)| *point).unwrap_or_else(|| { + let mut end = point; + *end.column_mut() += char.len_utf8() as u32; + map.clip_point(end, Bias::Left) + }); + + if char != '\n' { + whitespace_included = true; + } + } else { + // Found non whitespace. Quit out. + break; + } + } + + if !whitespace_included { + for (char, point) in map.reverse_chars_at(range.start) { + if char == '\n' && stop_at_newline { + break; + } + + if !char.is_whitespace() { + break; + } + + range.start = point; + } + } + + range +} + +fn surrounding_markers( + map: &DisplaySnapshot, + relative_to: DisplayPoint, + around: bool, + search_across_lines: bool, + start_marker: char, + end_marker: char, +) -> Option> { + let mut matched_ends = 0; + let mut start = None; + for (char, mut point) in map.reverse_chars_at(relative_to) { + if char == start_marker { + if matched_ends > 0 { + matched_ends -= 1; + } else { + if around { + start = Some(point) + } else { + *point.column_mut() += char.len_utf8() as u32; + start = Some(point); + } + break; + } + } else if char == end_marker { + matched_ends += 1; + } else if char == '\n' && !search_across_lines { + break; + } + } + + let mut matched_starts = 0; + let mut end = None; + for (char, mut point) in map.chars_at(relative_to) { + if char == end_marker { + if start.is_none() { + break; + } + + if matched_starts > 0 { + matched_starts -= 1; + } else { + if around { + *point.column_mut() += char.len_utf8() as u32; + end = Some(point); + } else { + end = Some(point); + } + + break; + } + } + + if char == start_marker { + if start.is_none() { + if around { + start = Some(point); + } else { + *point.column_mut() += char.len_utf8() as u32; + start = Some(point); + } + } else { + matched_starts += 1; + } + } + + if char == '\n' && !search_across_lines { + break; + } + } + + if let (Some(start), Some(end)) = (start, end) { + Some(start..end) + } else { + None + } +} + +#[cfg(test)] +mod test { + use indoc::indoc; + + use crate::test::NeovimBackedTestContext; + + const WORD_LOCATIONS: &'static str = indoc! {" + The quick ˇbrowˇnˇ + fox ˇjuˇmpsˇ over + the lazy dogˇ + ˇ + ˇ + ˇ + Thˇeˇ-ˇquˇickˇ ˇbrownˇ + ˇ + ˇ + ˇ fox-jumpˇs over + the lazy dogˇ + ˇ + "}; + + #[gpui::test] + async fn test_change_word_object(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.assert_binding_matches_all(["c", "i", "w"], WORD_LOCATIONS) + .await; + cx.assert_binding_matches_all(["c", "i", "shift-w"], WORD_LOCATIONS) + .await; + cx.assert_binding_matches_all(["c", "a", "w"], WORD_LOCATIONS) + .await; + cx.assert_binding_matches_all(["c", "a", "shift-w"], WORD_LOCATIONS) + .await; + } + + #[gpui::test] + async fn test_delete_word_object(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.assert_binding_matches_all(["d", "i", "w"], WORD_LOCATIONS) + .await; + cx.assert_binding_matches_all(["d", "i", "shift-w"], WORD_LOCATIONS) + .await; + cx.assert_binding_matches_all(["d", "a", "w"], WORD_LOCATIONS) + .await; + cx.assert_binding_matches_all(["d", "a", "shift-w"], WORD_LOCATIONS) + .await; + } + + #[gpui::test] + async fn test_visual_word_object(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.assert_binding_matches_all(["v", "i", "w"], WORD_LOCATIONS) + .await; + // Visual text objects are slightly broken when used with non empty selections + // cx.assert_binding_matches_all(["v", "h", "i", "w"], WORD_LOCATIONS) + // .await; + // cx.assert_binding_matches_all(["v", "l", "i", "w"], WORD_LOCATIONS) + // .await; + cx.assert_binding_matches_all(["v", "i", "shift-w"], WORD_LOCATIONS) + .await; + + // Visual text objects are slightly broken when used with non empty selections + // cx.assert_binding_matches_all(["v", "i", "h", "shift-w"], WORD_LOCATIONS) + // .await; + // cx.assert_binding_matches_all(["v", "i", "l", "shift-w"], WORD_LOCATIONS) + // .await; + + // Visual around words is somewhat broken right now when it comes to newlines + // cx.assert_binding_matches_all(["v", "a", "w"], WORD_LOCATIONS) + // .await; + // cx.assert_binding_matches_all(["v", "a", "shift-w"], WORD_LOCATIONS) + // .await; + } + + const SENTENCE_EXAMPLES: &[&'static str] = &[ + "ˇThe quick ˇbrownˇ?ˇ ˇFox Jˇumpsˇ!ˇ Ovˇer theˇ lazyˇ.", + indoc! {" + ˇThe quick ˇbrownˇ + fox jumps over + the lazy doˇgˇ.ˇ ˇThe quick ˇ + brown fox jumps over + "}, + // Position of the cursor after deletion between lines isn't quite right. + // Deletion in a sentence at the start of a line with whitespace is incorrect. + // indoc! {" + // The quick brown fox jumps. + // Over the lazy dog + // ˇ + // ˇ + // ˇ fox-jumpˇs over + // the lazy dog.ˇ + // ˇ + // "}, + r#"ˇThe ˇquick brownˇ.)ˇ]ˇ'ˇ" Brown ˇfox jumpsˇ.ˇ "#, + ]; + + #[gpui::test] + async fn test_change_sentence_object(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx) + .await + .binding(["c", "i", "s"]); + for sentence_example in SENTENCE_EXAMPLES { + cx.assert_all(sentence_example).await; + } + + let mut cx = cx.binding(["c", "a", "s"]); + // Resulting position is slightly incorrect for unintuitive reasons. + cx.add_initial_state_exemption("The quick brown?ˇ Fox Jumps! Over the lazy."); + // Changing around the sentence at the end of the line doesn't remove whitespace.' + cx.add_initial_state_exemption("The quick brown.)]\'\" Brown fox jumps.ˇ "); + + for sentence_example in SENTENCE_EXAMPLES { + cx.assert_all(sentence_example).await; + } + } + + #[gpui::test] + async fn test_delete_sentence_object(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx) + .await + .binding(["d", "i", "s"]); + for sentence_example in SENTENCE_EXAMPLES { + cx.assert_all(sentence_example).await; + } + + let mut cx = cx.binding(["d", "a", "s"]); + // Resulting position is slightly incorrect for unintuitive reasons. + cx.add_initial_state_exemption("The quick brown?ˇ Fox Jumps! Over the lazy."); + // Changing around the sentence at the end of the line doesn't remove whitespace.' + cx.add_initial_state_exemption("The quick brown.)]\'\" Brown fox jumps.ˇ "); + + for sentence_example in SENTENCE_EXAMPLES { + cx.assert_all(sentence_example).await; + } + } + + #[gpui::test] + async fn test_visual_sentence_object(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx) + .await + .binding(["v", "i", "s"]); + for sentence_example in SENTENCE_EXAMPLES { + cx.assert_all(sentence_example).await; + } + + // Visual around sentences is somewhat broken right now when it comes to newlines + // let mut cx = cx.binding(["d", "a", "s"]); + // for sentence_example in SENTENCE_EXAMPLES { + // cx.assert_all(sentence_example).await; + // } + } + + // Test string with "`" for opening surrounders and "'" for closing surrounders + const SURROUNDING_MARKER_STRING: &str = indoc! {" + ˇTh'ˇe ˇ`ˇ'ˇquˇi`ˇck broˇ'wn` + 'ˇfox juˇmps ovˇ`ˇer + the ˇlazy dˇ'ˇoˇ`ˇg"}; + + const SURROUNDING_OBJECTS: &[(char, char)] = &[ + // ('\'', '\''), // Quote, + // ('`', '`'), // Back Quote + // ('"', '"'), // Double Quote + // ('"', '"'), // Double Quote + ('(', ')'), // Parentheses + ('[', ']'), // SquareBrackets + ('{', '}'), // CurlyBrackets + ('<', '>'), // AngleBrackets + ]; + + #[gpui::test] + async fn test_change_surrounding_character_objects(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + for (start, end) in SURROUNDING_OBJECTS { + let marked_string = SURROUNDING_MARKER_STRING + .replace('`', &start.to_string()) + .replace('\'', &end.to_string()); + + // cx.assert_binding_matches_all(["c", "i", &start.to_string()], &marked_string) + // .await; + cx.assert_binding_matches_all(["c", "i", &end.to_string()], &marked_string) + .await; + // cx.assert_binding_matches_all(["c", "a", &start.to_string()], &marked_string) + // .await; + cx.assert_binding_matches_all(["c", "a", &end.to_string()], &marked_string) + .await; + } + } + + #[gpui::test] + async fn test_delete_surrounding_character_objects(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + for (start, end) in SURROUNDING_OBJECTS { + let marked_string = SURROUNDING_MARKER_STRING + .replace('`', &start.to_string()) + .replace('\'', &end.to_string()); + + // cx.assert_binding_matches_all(["d", "i", &start.to_string()], &marked_string) + // .await; + cx.assert_binding_matches_all(["d", "i", &end.to_string()], &marked_string) + .await; + // cx.assert_binding_matches_all(["d", "a", &start.to_string()], &marked_string) + // .await; + cx.assert_binding_matches_all(["d", "a", &end.to_string()], &marked_string) + .await; + } + } +} diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index e556048ea8..fef0da2099 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -1,8 +1,8 @@ use editor::CursorShape; use gpui::keymap::Context; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; -#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)] pub enum Mode { Normal, Insert, @@ -22,10 +22,12 @@ pub enum Namespace { #[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)] pub enum Operator { + Number(usize), Namespace(Namespace), Change, Delete, Yank, + Object { around: bool }, } #[derive(Default)] @@ -77,7 +79,12 @@ impl VimState { context.set.insert("VimControl".to_string()); } - Operator::set_context(self.operator_stack.last(), &mut context); + let active_operator = self.operator_stack.last(); + if matches!(active_operator, Some(Operator::Object { .. })) { + context.set.insert("VimObject".to_string()); + } + + Operator::set_context(active_operator, &mut context); context } @@ -86,10 +93,14 @@ impl VimState { impl Operator { pub fn set_context(operator: Option<&Operator>, context: &mut Context) { let operator_context = match operator { + Some(Operator::Number(_)) => "n", Some(Operator::Namespace(Namespace::G)) => "g", + Some(Operator::Object { around: false }) => "i", + Some(Operator::Object { around: true }) => "a", Some(Operator::Change) => "c", Some(Operator::Delete) => "d", Some(Operator::Yank) => "y", + None => "none", } .to_owned(); diff --git a/crates/vim/src/test.rs b/crates/vim/src/test.rs new file mode 100644 index 0000000000..e320962cfa --- /dev/null +++ b/crates/vim/src/test.rs @@ -0,0 +1,103 @@ +mod neovim_backed_binding_test_context; +mod neovim_backed_test_context; +mod neovim_connection; +mod vim_binding_test_context; +mod vim_test_context; + +pub use neovim_backed_binding_test_context::*; +pub use neovim_backed_test_context::*; +pub use vim_binding_test_context::*; +pub use vim_test_context::*; + +use indoc::indoc; +use search::BufferSearchBar; + +use crate::state::Mode; + +#[gpui::test] +async fn test_initially_disabled(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, false).await; + cx.simulate_keystrokes(["h", "j", "k", "l"]); + cx.assert_editor_state("hjklˇ"); +} + +#[gpui::test] +async fn test_neovim(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.simulate_shared_keystroke("i").await; + cx.assert_state_matches().await; + cx.simulate_shared_keystrokes([ + "shift-T", "e", "s", "t", " ", "t", "e", "s", "t", "escape", "0", "d", "w", + ]) + .await; + cx.assert_state_matches().await; + cx.assert_editor_state("ˇtest"); +} + +#[gpui::test] +async fn test_toggle_through_settings(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + cx.simulate_keystroke("i"); + assert_eq!(cx.mode(), Mode::Insert); + + // Editor acts as though vim is disabled + cx.disable_vim(); + cx.simulate_keystrokes(["h", "j", "k", "l"]); + cx.assert_editor_state("hjklˇ"); + + // Selections aren't changed if editor is blurred but vim-mode is still disabled. + cx.set_state("«hjklˇ»", Mode::Normal); + cx.assert_editor_state("«hjklˇ»"); + cx.update_editor(|_, cx| cx.blur()); + cx.assert_editor_state("«hjklˇ»"); + cx.update_editor(|_, cx| cx.focus_self()); + cx.assert_editor_state("«hjklˇ»"); + + // Enabling dynamically sets vim mode again and restores normal mode + cx.enable_vim(); + assert_eq!(cx.mode(), Mode::Normal); + cx.simulate_keystrokes(["h", "h", "h", "l"]); + assert_eq!(cx.buffer_text(), "hjkl".to_owned()); + cx.assert_editor_state("hˇjkl"); + cx.simulate_keystrokes(["i", "T", "e", "s", "t"]); + cx.assert_editor_state("hTestˇjkl"); + + // Disabling and enabling resets to normal mode + assert_eq!(cx.mode(), Mode::Insert); + cx.disable_vim(); + cx.enable_vim(); + assert_eq!(cx.mode(), Mode::Normal); +} + +#[gpui::test] +async fn test_buffer_search(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + cx.set_state( + indoc! {" + The quick brown + fox juˇmps over + the lazy dog"}, + Mode::Normal, + ); + cx.simulate_keystroke("/"); + + // We now use a weird insert mode with selection when jumping to a single line editor + assert_eq!(cx.mode(), Mode::Insert); + + let search_bar = cx.workspace(|workspace, cx| { + workspace + .active_pane() + .read(cx) + .toolbar() + .read(cx) + .item_of_type::() + .expect("Buffer search bar should be deployed") + }); + + search_bar.read_with(cx.cx, |bar, cx| { + assert_eq!(bar.query_editor.read(cx).text(cx), "jumps"); + }) +} diff --git a/crates/vim/src/test/neovim_backed_binding_test_context.rs b/crates/vim/src/test/neovim_backed_binding_test_context.rs new file mode 100644 index 0000000000..a768aff59d --- /dev/null +++ b/crates/vim/src/test/neovim_backed_binding_test_context.rs @@ -0,0 +1,80 @@ +use std::ops::{Deref, DerefMut}; + +use gpui::ContextHandle; + +use crate::state::Mode; + +use super::NeovimBackedTestContext; + +pub struct NeovimBackedBindingTestContext<'a, const COUNT: usize> { + cx: NeovimBackedTestContext<'a>, + keystrokes_under_test: [&'static str; COUNT], +} + +impl<'a, const COUNT: usize> NeovimBackedBindingTestContext<'a, COUNT> { + pub fn new( + keystrokes_under_test: [&'static str; COUNT], + cx: NeovimBackedTestContext<'a>, + ) -> Self { + Self { + cx, + keystrokes_under_test, + } + } + + pub fn consume(self) -> NeovimBackedTestContext<'a> { + self.cx + } + + pub fn binding( + self, + keystrokes: [&'static str; NEW_COUNT], + ) -> NeovimBackedBindingTestContext<'a, NEW_COUNT> { + self.consume().binding(keystrokes) + } + + pub async fn assert( + &mut self, + marked_positions: &str, + ) -> Option<(ContextHandle, ContextHandle)> { + self.cx + .assert_binding_matches(self.keystrokes_under_test, marked_positions) + .await + } + + pub fn assert_manual( + &mut self, + initial_state: &str, + mode_before: Mode, + state_after: &str, + mode_after: Mode, + ) { + self.cx.assert_binding( + self.keystrokes_under_test, + initial_state, + mode_before, + state_after, + mode_after, + ); + } + + pub async fn assert_all(&mut self, marked_positions: &str) { + self.cx + .assert_binding_matches_all(self.keystrokes_under_test, marked_positions) + .await + } +} + +impl<'a, const COUNT: usize> Deref for NeovimBackedBindingTestContext<'a, COUNT> { + type Target = NeovimBackedTestContext<'a>; + + fn deref(&self) -> &Self::Target { + &self.cx + } +} + +impl<'a, const COUNT: usize> DerefMut for NeovimBackedBindingTestContext<'a, COUNT> { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.cx + } +} diff --git a/crates/vim/src/test/neovim_backed_test_context.rs b/crates/vim/src/test/neovim_backed_test_context.rs new file mode 100644 index 0000000000..bb8ba26b74 --- /dev/null +++ b/crates/vim/src/test/neovim_backed_test_context.rs @@ -0,0 +1,158 @@ +use std::ops::{Deref, DerefMut}; + +use collections::{HashMap, HashSet}; +use gpui::ContextHandle; +use language::OffsetRangeExt; +use util::test::marked_text_offsets; + +use super::{neovim_connection::NeovimConnection, NeovimBackedBindingTestContext, VimTestContext}; +use crate::state::Mode; + +pub struct NeovimBackedTestContext<'a> { + cx: VimTestContext<'a>, + // Lookup for exempted assertions. Keyed by the insertion text, and with a value indicating which + // bindings are exempted. If None, all bindings are ignored for that insertion text. + exemptions: HashMap>>, + neovim: NeovimConnection, +} + +impl<'a> NeovimBackedTestContext<'a> { + pub async fn new(cx: &'a mut gpui::TestAppContext) -> NeovimBackedTestContext<'a> { + let function_name = cx.function_name.clone(); + let cx = VimTestContext::new(cx, true).await; + Self { + cx, + exemptions: Default::default(), + neovim: NeovimConnection::new(function_name).await, + } + } + + pub fn add_initial_state_exemption(&mut self, initial_state: &str) { + let initial_state = initial_state.to_string(); + // None represents all keybindings being exempted for that initial state + self.exemptions.insert(initial_state, None); + } + + pub async fn simulate_shared_keystroke(&mut self, keystroke_text: &str) -> ContextHandle { + self.neovim.send_keystroke(keystroke_text).await; + self.simulate_keystroke(keystroke_text) + } + + pub async fn simulate_shared_keystrokes( + &mut self, + keystroke_texts: [&str; COUNT], + ) -> ContextHandle { + for keystroke_text in keystroke_texts.into_iter() { + self.neovim.send_keystroke(keystroke_text).await; + } + self.simulate_keystrokes(keystroke_texts) + } + + pub async fn set_shared_state(&mut self, marked_text: &str) -> ContextHandle { + let context_handle = self.set_state(marked_text, Mode::Normal); + + let selection = self.editor(|editor, cx| editor.selections.newest::(cx)); + let text = self.buffer_text(); + self.neovim.set_state(selection, &text).await; + + context_handle + } + + pub async fn assert_state_matches(&mut self) { + assert_eq!( + self.neovim.text().await, + self.buffer_text(), + "{}", + self.assertion_context() + ); + + let mut neovim_selection = self.neovim.selection().await; + // Zed selections adjust themselves to make the end point visually make sense + if neovim_selection.start > neovim_selection.end { + neovim_selection.start.column += 1; + } + let neovim_selection = neovim_selection.to_offset(&self.buffer_snapshot()); + self.assert_editor_selections(vec![neovim_selection]); + + if let Some(neovim_mode) = self.neovim.mode().await { + assert_eq!(neovim_mode, self.mode(), "{}", self.assertion_context(),); + } + } + + pub async fn assert_binding_matches( + &mut self, + keystrokes: [&str; COUNT], + initial_state: &str, + ) -> Option<(ContextHandle, ContextHandle)> { + if let Some(possible_exempted_keystrokes) = self.exemptions.get(initial_state) { + match possible_exempted_keystrokes { + Some(exempted_keystrokes) => { + if exempted_keystrokes.contains(&format!("{keystrokes:?}")) { + // This keystroke was exempted for this insertion text + return None; + } + } + None => { + // All keystrokes for this insertion text are exempted + return None; + } + } + } + + let _state_context = self.set_shared_state(initial_state).await; + let _keystroke_context = self.simulate_shared_keystrokes(keystrokes).await; + self.assert_state_matches().await; + Some((_state_context, _keystroke_context)) + } + + pub async fn assert_binding_matches_all( + &mut self, + keystrokes: [&str; COUNT], + marked_positions: &str, + ) { + let (unmarked_text, cursor_offsets) = marked_text_offsets(marked_positions); + + for cursor_offset in cursor_offsets.iter() { + let mut marked_text = unmarked_text.clone(); + marked_text.insert(*cursor_offset, 'ˇ'); + + self.assert_binding_matches(keystrokes, &marked_text).await; + } + } + + pub fn binding( + self, + keystrokes: [&'static str; COUNT], + ) -> NeovimBackedBindingTestContext<'a, COUNT> { + NeovimBackedBindingTestContext::new(keystrokes, self) + } +} + +impl<'a> Deref for NeovimBackedTestContext<'a> { + type Target = VimTestContext<'a>; + + fn deref(&self) -> &Self::Target { + &self.cx + } +} + +impl<'a> DerefMut for NeovimBackedTestContext<'a> { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.cx + } +} + +#[cfg(test)] +mod test { + use gpui::TestAppContext; + + use crate::test::NeovimBackedTestContext; + + #[gpui::test] + async fn neovim_backed_test_context_works(cx: &mut TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + cx.assert_state_matches().await; + cx.set_shared_state("This is a tesˇt").await; + cx.assert_state_matches().await; + } +} diff --git a/crates/vim/src/test/neovim_connection.rs b/crates/vim/src/test/neovim_connection.rs new file mode 100644 index 0000000000..ff4e10cfe5 --- /dev/null +++ b/crates/vim/src/test/neovim_connection.rs @@ -0,0 +1,383 @@ +#[cfg(feature = "neovim")] +use std::ops::{Deref, DerefMut}; +use std::{ops::Range, path::PathBuf}; + +#[cfg(feature = "neovim")] +use async_compat::Compat; +#[cfg(feature = "neovim")] +use async_trait::async_trait; +#[cfg(feature = "neovim")] +use gpui::keymap::Keystroke; +use language::{Point, Selection}; +#[cfg(feature = "neovim")] +use lazy_static::lazy_static; +#[cfg(feature = "neovim")] +use nvim_rs::{ + create::tokio::new_child_cmd, error::LoopError, Handler, Neovim, UiAttachOptions, Value, +}; +#[cfg(feature = "neovim")] +use parking_lot::ReentrantMutex; +use serde::{Deserialize, Serialize}; +#[cfg(feature = "neovim")] +use tokio::{ + process::{Child, ChildStdin, Command}, + task::JoinHandle, +}; + +use crate::state::Mode; +use collections::VecDeque; + +// Neovim doesn't like to be started simultaneously from multiple threads. We use thsi lock +// to ensure we are only constructing one neovim connection at a time. +#[cfg(feature = "neovim")] +lazy_static! { + static ref NEOVIM_LOCK: ReentrantMutex<()> = ReentrantMutex::new(()); +} + +#[derive(Serialize, Deserialize)] +pub enum NeovimData { + Text(String), + Selection { start: (u32, u32), end: (u32, u32) }, + Mode(Option), +} + +pub struct NeovimConnection { + data: VecDeque, + #[cfg(feature = "neovim")] + test_case_id: String, + #[cfg(feature = "neovim")] + nvim: Neovim>, + #[cfg(feature = "neovim")] + _join_handle: JoinHandle>>, + #[cfg(feature = "neovim")] + _child: Child, +} + +impl NeovimConnection { + pub async fn new(test_case_id: String) -> Self { + #[cfg(feature = "neovim")] + let handler = NvimHandler {}; + #[cfg(feature = "neovim")] + let (nvim, join_handle, child) = Compat::new(async { + // Ensure we don't create neovim connections in parallel + let _lock = NEOVIM_LOCK.lock(); + let (nvim, join_handle, child) = new_child_cmd( + &mut Command::new("nvim").arg("--embed").arg("--clean"), + handler, + ) + .await + .expect("Could not connect to neovim process"); + + nvim.ui_attach(100, 100, &UiAttachOptions::default()) + .await + .expect("Could not attach to ui"); + + // Makes system act a little more like zed in terms of indentation + nvim.set_option("smartindent", nvim_rs::Value::Boolean(true)) + .await + .expect("Could not set smartindent on startup"); + + (nvim, join_handle, child) + }) + .await; + + Self { + #[cfg(feature = "neovim")] + data: Default::default(), + #[cfg(not(feature = "neovim"))] + data: Self::read_test_data(&test_case_id), + #[cfg(feature = "neovim")] + test_case_id, + #[cfg(feature = "neovim")] + nvim, + #[cfg(feature = "neovim")] + _join_handle: join_handle, + #[cfg(feature = "neovim")] + _child: child, + } + } + + // Sends a keystroke to the neovim process. + #[cfg(feature = "neovim")] + pub async fn send_keystroke(&mut self, keystroke_text: &str) { + let keystroke = Keystroke::parse(keystroke_text).unwrap(); + let special = keystroke.shift + || keystroke.ctrl + || keystroke.alt + || keystroke.cmd + || keystroke.key.len() > 1; + let start = if special { "<" } else { "" }; + let shift = if keystroke.shift { "S-" } else { "" }; + let ctrl = if keystroke.ctrl { "C-" } else { "" }; + let alt = if keystroke.alt { "M-" } else { "" }; + let cmd = if keystroke.cmd { "D-" } else { "" }; + let end = if special { ">" } else { "" }; + + let key = format!("{start}{shift}{ctrl}{alt}{cmd}{}{end}", keystroke.key); + + self.nvim + .input(&key) + .await + .expect("Could not input keystroke"); + } + + // If not running with a live neovim connection, this is a no-op + #[cfg(not(feature = "neovim"))] + pub async fn send_keystroke(&mut self, _keystroke_text: &str) {} + + #[cfg(feature = "neovim")] + pub async fn set_state(&mut self, selection: Selection, text: &str) { + let nvim_buffer = self + .nvim + .get_current_buf() + .await + .expect("Could not get neovim buffer"); + let lines = text + .split('\n') + .map(|line| line.to_string()) + .collect::>(); + + nvim_buffer + .set_lines(0, -1, false, lines) + .await + .expect("Could not set nvim buffer text"); + + self.nvim + .input("") + .await + .expect("Could not send escape to nvim"); + self.nvim + .input("") + .await + .expect("Could not send escape to nvim"); + + let nvim_window = self + .nvim + .get_current_win() + .await + .expect("Could not get neovim window"); + + if !selection.is_empty() { + panic!("Setting neovim state with non empty selection not yet supported"); + } + let cursor = selection.head(); + nvim_window + .set_cursor((cursor.row as i64 + 1, cursor.column as i64)) + .await + .expect("Could not set nvim cursor position"); + } + + #[cfg(not(feature = "neovim"))] + pub async fn set_state(&mut self, _selection: Selection, _text: &str) {} + + #[cfg(feature = "neovim")] + pub async fn text(&mut self) -> String { + let nvim_buffer = self + .nvim + .get_current_buf() + .await + .expect("Could not get neovim buffer"); + let text = nvim_buffer + .get_lines(0, -1, false) + .await + .expect("Could not get buffer text") + .join("\n"); + + self.data.push_back(NeovimData::Text(text.clone())); + + text + } + + #[cfg(not(feature = "neovim"))] + pub async fn text(&mut self) -> String { + if let Some(NeovimData::Text(text)) = self.data.pop_front() { + text + } else { + panic!("Invalid test data. Is test deterministic? Try running with '--features neovim' to regenerate"); + } + } + + #[cfg(feature = "neovim")] + pub async fn selection(&mut self) -> Range { + let cursor_row: u32 = self + .nvim + .command_output("echo line('.')") + .await + .unwrap() + .parse::() + .unwrap() + - 1; // Neovim rows start at 1 + let cursor_col: u32 = self + .nvim + .command_output("echo col('.')") + .await + .unwrap() + .parse::() + .unwrap() + - 1; // Neovim columns start at 1 + + let (start, end) = if let Some(Mode::Visual { .. }) = self.mode().await { + self.nvim + .input("") + .await + .expect("Could not exit visual mode"); + let nvim_buffer = self + .nvim + .get_current_buf() + .await + .expect("Could not get neovim buffer"); + let (start_row, start_col) = nvim_buffer + .get_mark("<") + .await + .expect("Could not get selection start"); + let (end_row, end_col) = nvim_buffer + .get_mark(">") + .await + .expect("Could not get selection end"); + self.nvim + .input("gv") + .await + .expect("Could not reselect visual selection"); + + if cursor_row == start_row as u32 - 1 && cursor_col == start_col as u32 { + ( + (end_row as u32 - 1, end_col as u32), + (start_row as u32 - 1, start_col as u32), + ) + } else { + ( + (start_row as u32 - 1, start_col as u32), + (end_row as u32 - 1, end_col as u32), + ) + } + } else { + ((cursor_row, cursor_col), (cursor_row, cursor_col)) + }; + + self.data.push_back(NeovimData::Selection { start, end }); + + Point::new(start.0, start.1)..Point::new(end.0, end.1) + } + + #[cfg(not(feature = "neovim"))] + pub async fn selection(&mut self) -> Range { + // Selection code fetches the mode. This emulates that. + let _mode = self.mode().await; + if let Some(NeovimData::Selection { start, end }) = self.data.pop_front() { + Point::new(start.0, start.1)..Point::new(end.0, end.1) + } else { + panic!("Invalid test data. Is test deterministic? Try running with '--features neovim' to regenerate"); + } + } + + #[cfg(feature = "neovim")] + pub async fn mode(&mut self) -> Option { + let nvim_mode_text = self + .nvim + .get_mode() + .await + .expect("Could not get mode") + .into_iter() + .find_map(|(key, value)| { + if key.as_str() == Some("mode") { + Some(value.as_str().unwrap().to_owned()) + } else { + None + } + }) + .expect("Could not find mode value"); + + let mode = match nvim_mode_text.as_ref() { + "i" => Some(Mode::Insert), + "n" => Some(Mode::Normal), + "v" => Some(Mode::Visual { line: false }), + "V" => Some(Mode::Visual { line: true }), + _ => None, + }; + + self.data.push_back(NeovimData::Mode(mode.clone())); + + mode + } + + #[cfg(not(feature = "neovim"))] + pub async fn mode(&mut self) -> Option { + if let Some(NeovimData::Mode(mode)) = self.data.pop_front() { + mode + } else { + panic!("Invalid test data. Is test deterministic? Try running with '--features neovim' to regenerate"); + } + } + + fn test_data_path(test_case_id: &str) -> PathBuf { + let mut data_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + data_path.push("test_data"); + data_path.push(format!("{}.json", test_case_id)); + data_path + } + + #[cfg(not(feature = "neovim"))] + fn read_test_data(test_case_id: &str) -> VecDeque { + let path = Self::test_data_path(test_case_id); + let json = std::fs::read_to_string(path).expect( + "Could not read test data. Is it generated? Try running test with '--features neovim'", + ); + + serde_json::from_str(&json) + .expect("Test data corrupted. Try regenerating it with '--features neovim'") + } +} + +#[cfg(feature = "neovim")] +impl Deref for NeovimConnection { + type Target = Neovim>; + + fn deref(&self) -> &Self::Target { + &self.nvim + } +} + +#[cfg(feature = "neovim")] +impl DerefMut for NeovimConnection { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.nvim + } +} + +#[cfg(feature = "neovim")] +impl Drop for NeovimConnection { + fn drop(&mut self) { + let path = Self::test_data_path(&self.test_case_id); + std::fs::create_dir_all(path.parent().unwrap()) + .expect("Could not create test data directory"); + let json = serde_json::to_string(&self.data).expect("Could not serialize test data"); + std::fs::write(path, json).expect("Could not write out test data"); + } +} + +#[cfg(feature = "neovim")] +#[derive(Clone)] +struct NvimHandler {} + +#[cfg(feature = "neovim")] +#[async_trait] +impl Handler for NvimHandler { + type Writer = nvim_rs::compat::tokio::Compat; + + async fn handle_request( + &self, + _event_name: String, + _arguments: Vec, + _neovim: Neovim, + ) -> Result { + unimplemented!(); + } + + async fn handle_notify( + &self, + _event_name: String, + _arguments: Vec, + _neovim: Neovim, + ) { + } +} diff --git a/crates/vim/src/test/vim_binding_test_context.rs b/crates/vim/src/test/vim_binding_test_context.rs new file mode 100644 index 0000000000..0974684a34 --- /dev/null +++ b/crates/vim/src/test/vim_binding_test_context.rs @@ -0,0 +1,69 @@ +use std::ops::{Deref, DerefMut}; + +use crate::*; + +use super::VimTestContext; + +pub struct VimBindingTestContext<'a, const COUNT: usize> { + cx: VimTestContext<'a>, + keystrokes_under_test: [&'static str; COUNT], + mode_before: Mode, + mode_after: Mode, +} + +impl<'a, const COUNT: usize> VimBindingTestContext<'a, COUNT> { + pub fn new( + keystrokes_under_test: [&'static str; COUNT], + mode_before: Mode, + mode_after: Mode, + cx: VimTestContext<'a>, + ) -> Self { + Self { + cx, + keystrokes_under_test, + mode_before, + mode_after, + } + } + + pub fn binding( + self, + keystrokes_under_test: [&'static str; NEW_COUNT], + ) -> VimBindingTestContext<'a, NEW_COUNT> { + VimBindingTestContext { + keystrokes_under_test, + cx: self.cx, + mode_before: self.mode_before, + mode_after: self.mode_after, + } + } + + pub fn mode_after(mut self, mode_after: Mode) -> Self { + self.mode_after = mode_after; + self + } + + pub fn assert(&mut self, initial_state: &str, state_after: &str) { + self.cx.assert_binding( + self.keystrokes_under_test, + initial_state, + self.mode_before, + state_after, + self.mode_after, + ) + } +} + +impl<'a, const COUNT: usize> Deref for VimBindingTestContext<'a, COUNT> { + type Target = VimTestContext<'a>; + + fn deref(&self) -> &Self::Target { + &self.cx + } +} + +impl<'a, const COUNT: usize> DerefMut for VimBindingTestContext<'a, COUNT> { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.cx + } +} diff --git a/crates/vim/src/vim_test_context.rs b/crates/vim/src/test/vim_test_context.rs similarity index 70% rename from crates/vim/src/vim_test_context.rs rename to crates/vim/src/test/vim_test_context.rs index 0e77b05ba2..2fb446d127 100644 --- a/crates/vim/src/vim_test_context.rs +++ b/crates/vim/src/test/vim_test_context.rs @@ -1,13 +1,15 @@ use std::ops::{Deref, DerefMut}; -use editor::test::EditorTestContext; -use gpui::{json::json, AppContext, ViewHandle}; +use editor::test::editor_test_context::EditorTestContext; +use gpui::{json::json, AppContext, ContextHandle, ViewHandle}; use project::Project; use search::{BufferSearchBar, ProjectSearchBar}; use workspace::{pane, AppState, WorkspaceHandle}; use crate::{state::Operator, *}; +use super::VimBindingTestContext; + pub struct VimTestContext<'a> { cx: EditorTestContext<'a>, workspace: ViewHandle, @@ -117,18 +119,18 @@ impl<'a> VimTestContext<'a> { .read(|cx| cx.global::().state.operator_stack.last().copied()) } - pub fn set_state(&mut self, text: &str, mode: Mode) { + pub fn set_state(&mut self, text: &str, mode: Mode) -> ContextHandle { self.cx.update(|cx| { Vim::update(cx, |vim, cx| { vim.switch_mode(mode, false, cx); }) }); - self.cx.set_state(text); + self.cx.set_state(text) } pub fn assert_state(&mut self, text: &str, mode: Mode) { self.assert_editor_state(text); - assert_eq!(self.mode(), mode); + assert_eq!(self.mode(), mode, "{}", self.assertion_context()); } pub fn assert_binding( @@ -142,8 +144,8 @@ impl<'a> VimTestContext<'a> { self.set_state(initial_state, initial_mode); self.cx.simulate_keystrokes(keystrokes); self.cx.assert_editor_state(state_after); - assert_eq!(self.mode(), mode_after); - assert_eq!(self.active_operator(), None); + assert_eq!(self.mode(), mode_after, "{}", self.assertion_context()); + assert_eq!(self.active_operator(), None, "{}", self.assertion_context()); } pub fn binding( @@ -168,67 +170,3 @@ impl<'a> DerefMut for VimTestContext<'a> { &mut self.cx } } - -pub struct VimBindingTestContext<'a, const COUNT: usize> { - cx: VimTestContext<'a>, - keystrokes_under_test: [&'static str; COUNT], - mode_before: Mode, - mode_after: Mode, -} - -impl<'a, const COUNT: usize> VimBindingTestContext<'a, COUNT> { - pub fn new( - keystrokes_under_test: [&'static str; COUNT], - mode_before: Mode, - mode_after: Mode, - cx: VimTestContext<'a>, - ) -> Self { - Self { - cx, - keystrokes_under_test, - mode_before, - mode_after, - } - } - - pub fn binding( - self, - keystrokes_under_test: [&'static str; NEW_COUNT], - ) -> VimBindingTestContext<'a, NEW_COUNT> { - VimBindingTestContext { - keystrokes_under_test, - cx: self.cx, - mode_before: self.mode_before, - mode_after: self.mode_after, - } - } - - pub fn mode_after(mut self, mode_after: Mode) -> Self { - self.mode_after = mode_after; - self - } - - pub fn assert(&mut self, initial_state: &str, state_after: &str) { - self.cx.assert_binding( - self.keystrokes_under_test, - initial_state, - self.mode_before, - state_after, - self.mode_after, - ) - } -} - -impl<'a, const COUNT: usize> Deref for VimBindingTestContext<'a, COUNT> { - type Target = VimTestContext<'a>; - - fn deref(&self) -> &Self::Target { - &self.cx - } -} - -impl<'a, const COUNT: usize> DerefMut for VimBindingTestContext<'a, COUNT> { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.cx - } -} diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index ecad33ce3f..81bafcf3e2 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -1,10 +1,11 @@ #[cfg(test)] -mod vim_test_context; +mod test; mod editor_events; mod insert; mod motion; mod normal; +mod object; mod state; mod utils; mod visual; @@ -25,13 +26,17 @@ pub struct SwitchMode(pub Mode); #[derive(Clone, Deserialize, PartialEq)] pub struct PushOperator(pub Operator); -impl_actions!(vim, [SwitchMode, PushOperator]); +#[derive(Clone, Deserialize, PartialEq)] +struct Number(u8); + +impl_actions!(vim, [Number, SwitchMode, PushOperator]); pub fn init(cx: &mut MutableAppContext) { editor_events::init(cx); normal::init(cx); visual::init(cx); insert::init(cx); + object::init(cx); motion::init(cx); // Vim Actions @@ -43,6 +48,9 @@ pub fn init(cx: &mut MutableAppContext) { Vim::update(cx, |vim, cx| vim.push_operator(operator, cx)) }, ); + cx.add_action(|_: &mut Workspace, n: &Number, cx: _| { + Vim::update(cx, |vim, cx| vim.push_number(n, cx)); + }); // Editor Actions cx.add_action(|_: &mut Editor, _: &Cancel, cx| { @@ -143,12 +151,31 @@ impl Vim { self.sync_vim_settings(cx); } + fn push_number(&mut self, Number(number): &Number, cx: &mut MutableAppContext) { + if let Some(Operator::Number(current_number)) = self.active_operator() { + self.pop_operator(cx); + self.push_operator(Operator::Number(current_number * 10 + *number as usize), cx); + } else { + self.push_operator(Operator::Number(*number as usize), cx); + } + } + fn pop_operator(&mut self, cx: &mut MutableAppContext) -> Operator { - let popped_operator = self.state.operator_stack.pop().expect("Operator popped when no operator was on the stack. This likely means there is an invalid keymap config"); + let popped_operator = self.state.operator_stack.pop() + .expect("Operator popped when no operator was on the stack. This likely means there is an invalid keymap config"); self.sync_vim_settings(cx); popped_operator } + fn pop_number_operator(&mut self, cx: &mut MutableAppContext) -> usize { + let mut times = 1; + if let Some(Operator::Number(number)) = self.active_operator() { + times = number; + self.pop_operator(cx); + } + times + } + fn clear_operator(&mut self, cx: &mut MutableAppContext) { self.state.operator_stack.clear(); self.sync_vim_settings(cx); @@ -204,85 +231,3 @@ impl Vim { } } } - -#[cfg(test)] -mod test { - use indoc::indoc; - use search::BufferSearchBar; - - use crate::{state::Mode, vim_test_context::VimTestContext}; - - #[gpui::test] - async fn test_initially_disabled(cx: &mut gpui::TestAppContext) { - let mut cx = VimTestContext::new(cx, false).await; - cx.simulate_keystrokes(["h", "j", "k", "l"]); - cx.assert_editor_state("hjklˇ"); - } - - #[gpui::test] - async fn test_toggle_through_settings(cx: &mut gpui::TestAppContext) { - let mut cx = VimTestContext::new(cx, true).await; - - cx.simulate_keystroke("i"); - assert_eq!(cx.mode(), Mode::Insert); - - // Editor acts as though vim is disabled - cx.disable_vim(); - cx.simulate_keystrokes(["h", "j", "k", "l"]); - cx.assert_editor_state("hjklˇ"); - - // Selections aren't changed if editor is blurred but vim-mode is still disabled. - cx.set_state("«hjklˇ»", Mode::Normal); - cx.assert_editor_state("«hjklˇ»"); - cx.update_editor(|_, cx| cx.blur()); - cx.assert_editor_state("«hjklˇ»"); - cx.update_editor(|_, cx| cx.focus_self()); - cx.assert_editor_state("«hjklˇ»"); - - // Enabling dynamically sets vim mode again and restores normal mode - cx.enable_vim(); - assert_eq!(cx.mode(), Mode::Normal); - cx.simulate_keystrokes(["h", "h", "h", "l"]); - assert_eq!(cx.buffer_text(), "hjkl".to_owned()); - cx.assert_editor_state("hˇjkl"); - cx.simulate_keystrokes(["i", "T", "e", "s", "t"]); - cx.assert_editor_state("hTestˇjkl"); - - // Disabling and enabling resets to normal mode - assert_eq!(cx.mode(), Mode::Insert); - cx.disable_vim(); - cx.enable_vim(); - assert_eq!(cx.mode(), Mode::Normal); - } - - #[gpui::test] - async fn test_buffer_search(cx: &mut gpui::TestAppContext) { - let mut cx = VimTestContext::new(cx, true).await; - - cx.set_state( - indoc! {" - The quick brown - fox juˇmps over - the lazy dog"}, - Mode::Normal, - ); - cx.simulate_keystroke("/"); - - // We now use a weird insert mode with selection when jumping to a single line editor - assert_eq!(cx.mode(), Mode::Insert); - - let search_bar = cx.workspace(|workspace, cx| { - workspace - .active_pane() - .read(cx) - .toolbar() - .read(cx) - .item_of_type::() - .expect("Buffer search bar should be deployed") - }); - - search_bar.read_with(cx.cx, |bar, cx| { - assert_eq!(bar.query_editor.read(cx).text(cx), "jumps"); - }) - } -} diff --git a/crates/vim/src/visual.rs b/crates/vim/src/visual.rs index d468393027..481d2570ae 100644 --- a/crates/vim/src/visual.rs +++ b/crates/vim/src/visual.rs @@ -6,7 +6,13 @@ use gpui::{actions, MutableAppContext, ViewContext}; use language::{AutoindentMode, SelectionGoal}; use workspace::Workspace; -use crate::{motion::Motion, state::Mode, utils::copy_selections_content, Vim}; +use crate::{ + motion::Motion, + object::Object, + state::{Mode, Operator}, + utils::copy_selections_content, + Vim, +}; actions!(vim, [VisualDelete, VisualChange, VisualYank, VisualPaste]); @@ -17,13 +23,15 @@ pub fn init(cx: &mut MutableAppContext) { cx.add_action(paste); } -pub fn visual_motion(motion: Motion, cx: &mut MutableAppContext) { +pub fn visual_motion(motion: Motion, times: usize, cx: &mut MutableAppContext) { Vim::update(cx, |vim, cx| { vim.update_active_editor(cx, |editor, cx| { editor.change_selections(Some(Autoscroll::Fit), cx, |s| { s.move_with(|map, selection| { - let (new_head, goal) = motion.move_point(map, selection.head(), selection.goal); let was_reversed = selection.reversed; + + let (new_head, goal) = + motion.move_point(map, selection.head(), selection.goal, times); selection.set_head(new_head, goal); if was_reversed && !selection.reversed { @@ -43,6 +51,36 @@ pub fn visual_motion(motion: Motion, cx: &mut MutableAppContext) { }); } +pub fn visual_object(object: Object, cx: &mut MutableAppContext) { + Vim::update(cx, |vim, cx| { + if let Operator::Object { around } = vim.pop_operator(cx) { + vim.update_active_editor(cx, |editor, cx| { + editor.change_selections(Some(Autoscroll::Fit), cx, |s| { + s.move_with(|map, selection| { + let head = selection.head(); + if let Some(mut range) = object.range(map, head, around) { + if !range.is_empty() { + if let Some((_, end)) = map.reverse_chars_at(range.end).next() { + range.end = end; + } + + if selection.is_empty() { + selection.start = range.start; + selection.end = range.end; + } else if selection.reversed { + selection.start = range.start; + } else { + selection.end = range.end; + } + } + } + }); + }); + }); + } + }); +} + pub fn change(_: &mut Workspace, _: &VisualChange, cx: &mut ViewContext) { Vim::update(cx, |vim, cx| { vim.update_active_editor(cx, |editor, cx| { @@ -274,365 +312,151 @@ pub fn paste(_: &mut Workspace, _: &VisualPaste, cx: &mut ViewContext mod test { use indoc::indoc; - use crate::{state::Mode, vim_test_context::VimTestContext}; + use crate::{ + state::Mode, + test::{NeovimBackedTestContext, VimTestContext}, + }; #[gpui::test] async fn test_enter_visual_mode(cx: &mut gpui::TestAppContext) { - let cx = VimTestContext::new(cx, true).await; - let mut cx = cx - .binding(["v", "w", "j"]) - .mode_after(Mode::Visual { line: false }); - cx.assert( - indoc! {" + let mut cx = NeovimBackedTestContext::new(cx) + .await + .binding(["v", "w", "j"]); + cx.assert_all(indoc! {" The ˇquick brown - fox jumps over - the lazy dog"}, - indoc! {" - The «quick brown - fox jumps ˇ»over - the lazy dog"}, - ); - cx.assert( - indoc! {" - The quick brown - fox jumps over - the ˇlazy dog"}, - indoc! {" - The quick brown - fox jumps over - the «lazy ˇ»dog"}, - ); - cx.assert( - indoc! {" - The quick brown fox jumps ˇover - the lazy dog"}, - indoc! {" - The quick brown - fox jumps «over - ˇ»the lazy dog"}, - ); - let mut cx = cx - .binding(["v", "b", "k"]) - .mode_after(Mode::Visual { line: false }); - cx.assert( - indoc! {" + the ˇlazy dog"}) + .await; + let mut cx = cx.binding(["v", "b", "k"]); + cx.assert_all(indoc! {" The ˇquick brown - fox jumps over - the lazy dog"}, - indoc! {" - «ˇThe q»uick brown - fox jumps over - the lazy dog"}, - ); - cx.assert( - indoc! {" - The quick brown - fox jumps over - the ˇlazy dog"}, - indoc! {" - The quick brown - «ˇfox jumps over - the l»azy dog"}, - ); - cx.assert( - indoc! {" - The quick brown fox jumps ˇover - the lazy dog"}, - indoc! {" - The «ˇquick brown - fox jumps o»ver - the lazy dog"}, - ); + the ˇlazy dog"}) + .await; } #[gpui::test] async fn test_visual_delete(cx: &mut gpui::TestAppContext) { - let cx = VimTestContext::new(cx, true).await; - let mut cx = cx.binding(["v", "w", "x"]); - cx.assert("The quick ˇbrown", "The quickˇ "); - let mut cx = cx.binding(["v", "w", "j", "x"]); - cx.assert( - indoc! {" - The ˇquick brown - fox jumps over - the lazy dog"}, - indoc! {" - The ˇver - the lazy dog"}, - ); - // Test pasting code copied on delete - cx.simulate_keystrokes(["j", "p"]); - cx.assert_editor_state(indoc! {" - The ver - the lˇquick brown - fox jumps oazy dog"}); + let mut cx = NeovimBackedTestContext::new(cx).await; - cx.assert( - indoc! {" - The quick brown - fox jumps over - the ˇlazy dog"}, - indoc! {" - The quick brown - fox jumps over - the ˇog"}, - ); - cx.assert( - indoc! {" - The quick brown - fox jumps ˇover - the lazy dog"}, - indoc! {" - The quick brown - fox jumps ˇhe lazy dog"}, - ); - let mut cx = cx.binding(["v", "b", "k", "x"]); - cx.assert( + cx.assert_binding_matches(["v", "w", "x"], "The quick ˇbrown") + .await; + cx.assert_binding_matches( + ["v", "w", "j", "x"], indoc! {" The ˇquick brown fox jumps over the lazy dog"}, - indoc! {" - ˇuick brown + ) + .await; + // Test pasting code copied on delete + cx.simulate_shared_keystrokes(["j", "p"]).await; + cx.assert_state_matches().await; + + let mut cx = cx.binding(["v", "w", "j", "x"]); + cx.assert_all(indoc! {" + The ˇquick brown fox jumps over - the lazy dog"}, - ); - cx.assert( - indoc! {" - The quick brown - fox jumps over - the ˇlazy dog"}, - indoc! {" - The quick brown - ˇazy dog"}, - ); - cx.assert( - indoc! {" - The quick brown + the ˇlazy dog"}) + .await; + let mut cx = cx.binding(["v", "b", "k", "x"]); + cx.assert_all(indoc! {" + The ˇquick brown fox jumps ˇover - the lazy dog"}, - indoc! {" - The ˇver - the lazy dog"}, - ); + the ˇlazy dog"}) + .await; } #[gpui::test] async fn test_visual_line_delete(cx: &mut gpui::TestAppContext) { - let cx = VimTestContext::new(cx, true).await; - let mut cx = cx.binding(["shift-v", "x"]); - cx.assert( - indoc! {" + let mut cx = NeovimBackedTestContext::new(cx) + .await + .binding(["shift-v", "x"]); + cx.assert(indoc! {" The quˇick brown fox jumps over - the lazy dog"}, - indoc! {" - fox juˇmps over - the lazy dog"}, - ); + the lazy dog"}) + .await; // Test pasting code copied on delete - cx.simulate_keystroke("p"); - cx.assert_editor_state(indoc! {" - fox jumps over - ˇThe quick brown - the lazy dog"}); + cx.simulate_shared_keystroke("p").await; + cx.assert_state_matches().await; - cx.assert( - indoc! {" + cx.assert_all(indoc! {" The quick brown fox juˇmps over - the lazy dog"}, - indoc! {" - The quick brown - the laˇzy dog"}, - ); - cx.assert( - indoc! {" - The quick brown - fox jumps over - the laˇzy dog"}, - indoc! {" - The quick brown - fox juˇmps over"}, - ); + the laˇzy dog"}) + .await; let mut cx = cx.binding(["shift-v", "j", "x"]); - cx.assert( - indoc! {" + cx.assert(indoc! {" The quˇick brown fox jumps over - the lazy dog"}, - "the laˇzy dog", - ); + the lazy dog"}) + .await; // Test pasting code copied on delete - cx.simulate_keystroke("p"); - cx.assert_editor_state(indoc! {" - the lazy dog - ˇThe quick brown - fox jumps over"}); + cx.simulate_shared_keystroke("p").await; + cx.assert_state_matches().await; - cx.assert( - indoc! {" + cx.assert_all(indoc! {" The quick brown fox juˇmps over - the lazy dog"}, - "The quˇick brown", - ); - cx.assert( - indoc! {" - The quick brown - fox jumps over - the laˇzy dog"}, - indoc! {" - The quick brown - fox juˇmps over"}, - ); + the laˇzy dog"}) + .await; } #[gpui::test] async fn test_visual_change(cx: &mut gpui::TestAppContext) { - let cx = VimTestContext::new(cx, true).await; - let mut cx = cx.binding(["v", "w", "c"]).mode_after(Mode::Insert); - cx.assert("The quick ˇbrown", "The quick ˇ"); - let mut cx = cx.binding(["v", "w", "j", "c"]).mode_after(Mode::Insert); - cx.assert( - indoc! {" + let mut cx = NeovimBackedTestContext::new(cx) + .await + .binding(["v", "w", "c"]); + cx.assert("The quick ˇbrown").await; + let mut cx = cx.binding(["v", "w", "j", "c"]); + cx.assert_all(indoc! {" The ˇquick brown - fox jumps over - the lazy dog"}, - indoc! {" - The ˇver - the lazy dog"}, - ); - cx.assert( - indoc! {" - The quick brown - fox jumps over - the ˇlazy dog"}, - indoc! {" - The quick brown - fox jumps over - the ˇog"}, - ); - cx.assert( - indoc! {" - The quick brown fox jumps ˇover - the lazy dog"}, - indoc! {" - The quick brown - fox jumps ˇhe lazy dog"}, - ); - let mut cx = cx.binding(["v", "b", "k", "c"]).mode_after(Mode::Insert); - cx.assert( - indoc! {" + the ˇlazy dog"}) + .await; + let mut cx = cx.binding(["v", "b", "k", "c"]); + cx.assert_all(indoc! {" The ˇquick brown - fox jumps over - the lazy dog"}, - indoc! {" - ˇuick brown - fox jumps over - the lazy dog"}, - ); - cx.assert( - indoc! {" - The quick brown - fox jumps over - the ˇlazy dog"}, - indoc! {" - The quick brown - ˇazy dog"}, - ); - cx.assert( - indoc! {" - The quick brown fox jumps ˇover - the lazy dog"}, - indoc! {" - The ˇver - the lazy dog"}, - ); + the ˇlazy dog"}) + .await; } #[gpui::test] async fn test_visual_line_change(cx: &mut gpui::TestAppContext) { - let cx = VimTestContext::new(cx, true).await; - let mut cx = cx.binding(["shift-v", "c"]).mode_after(Mode::Insert); - cx.assert( - indoc! {" + let mut cx = NeovimBackedTestContext::new(cx) + .await + .binding(["shift-v", "c"]); + cx.assert(indoc! {" The quˇick brown fox jumps over - the lazy dog"}, - indoc! {" - ˇ - fox jumps over - the lazy dog"}, - ); + the lazy dog"}) + .await; // Test pasting code copied on change - cx.simulate_keystrokes(["escape", "j", "p"]); - cx.assert_editor_state(indoc! {" - - fox jumps over - ˇThe quick brown - the lazy dog"}); + cx.simulate_shared_keystrokes(["escape", "j", "p"]).await; + cx.assert_state_matches().await; - cx.assert( - indoc! {" + cx.assert_all(indoc! {" The quick brown fox juˇmps over - the lazy dog"}, - indoc! {" - The quick brown - ˇ - the lazy dog"}, - ); - cx.assert( - indoc! {" - The quick brown - fox jumps over - the laˇzy dog"}, - indoc! {" - The quick brown - fox jumps over - ˇ"}, - ); - let mut cx = cx.binding(["shift-v", "j", "c"]).mode_after(Mode::Insert); - cx.assert( - indoc! {" + the laˇzy dog"}) + .await; + let mut cx = cx.binding(["shift-v", "j", "c"]); + cx.assert(indoc! {" The quˇick brown fox jumps over - the lazy dog"}, - indoc! {" - ˇ - the lazy dog"}, - ); + the lazy dog"}) + .await; // Test pasting code copied on delete - cx.simulate_keystrokes(["escape", "j", "p"]); - cx.assert_editor_state(indoc! {" - - the lazy dog - ˇThe quick brown - fox jumps over"}); - cx.assert( - indoc! {" + cx.simulate_shared_keystrokes(["escape", "j", "p"]).await; + cx.assert_state_matches().await; + + cx.assert_all(indoc! {" The quick brown fox juˇmps over - the lazy dog"}, - indoc! {" - The quick brown - ˇ"}, - ); - cx.assert( - indoc! {" - The quick brown - fox jumps over - the laˇzy dog"}, - indoc! {" - The quick brown - fox jumps over - ˇ"}, - ); + the laˇzy dog"}) + .await; } #[gpui::test] @@ -741,7 +565,7 @@ mod test { cx.assert_state( indoc! {" The quick brown - fox jumpsˇjumps over + fox jumpsjumpˇs over the lazy dog"}, Mode::Normal, ); diff --git a/crates/vim/test_data/neovim_backed_test_context_works.json b/crates/vim/test_data/neovim_backed_test_context_works.json new file mode 100644 index 0000000000..807c9010e8 --- /dev/null +++ b/crates/vim/test_data/neovim_backed_test_context_works.json @@ -0,0 +1 @@ +[{"Text":""},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"This is a test"},{"Mode":"Normal"},{"Selection":{"start":[0,13],"end":[0,13]}},{"Mode":"Normal"}] \ No newline at end of file diff --git a/crates/vim/test_data/test_a.json b/crates/vim/test_data/test_a.json new file mode 100644 index 0000000000..32ea8ac6a6 --- /dev/null +++ b/crates/vim/test_data/test_a.json @@ -0,0 +1 @@ +[{"Text":"The quick"},{"Mode":"Insert"},{"Selection":{"start":[0,6],"end":[0,6]}},{"Mode":"Insert"},{"Text":"The quick"},{"Mode":"Insert"},{"Selection":{"start":[0,9],"end":[0,9]}},{"Mode":"Insert"}] \ No newline at end of file diff --git a/crates/vim/test_data/test_b.json b/crates/vim/test_data/test_b.json new file mode 100644 index 0000000000..635edf536b --- /dev/null +++ b/crates/vim/test_data/test_b.json @@ -0,0 +1 @@ +[{"Text":"The quick-brown\n\n\nfox_jumps over\nthe"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"The quick-brown\n\n\nfox_jumps over\nthe"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"The quick-brown\n\n\nfox_jumps over\nthe"},{"Mode":"Normal"},{"Selection":{"start":[0,4],"end":[0,4]}},{"Mode":"Normal"},{"Text":"The quick-brown\n\n\nfox_jumps over\nthe"},{"Mode":"Normal"},{"Selection":{"start":[0,9],"end":[0,9]}},{"Mode":"Normal"},{"Text":"The quick-brown\n\n\nfox_jumps over\nthe"},{"Mode":"Normal"},{"Selection":{"start":[0,10],"end":[0,10]}},{"Mode":"Normal"},{"Text":"The quick-brown\n\n\nfox_jumps over\nthe"},{"Mode":"Normal"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Normal"},{"Text":"The quick-brown\n\n\nfox_jumps over\nthe"},{"Mode":"Normal"},{"Selection":{"start":[2,0],"end":[2,0]}},{"Mode":"Normal"},{"Text":"The quick-brown\n\n\nfox_jumps over\nthe"},{"Mode":"Normal"},{"Selection":{"start":[3,0],"end":[3,0]}},{"Mode":"Normal"},{"Text":"The quick-brown\n\n\nfox_jumps over\nthe"},{"Mode":"Normal"},{"Selection":{"start":[3,10],"end":[3,10]}},{"Mode":"Normal"},{"Text":"The quick-brown\n\n\nfox_jumps over\nthe"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"The quick-brown\n\n\nfox_jumps over\nthe"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"The quick-brown\n\n\nfox_jumps over\nthe"},{"Mode":"Normal"},{"Selection":{"start":[0,4],"end":[0,4]}},{"Mode":"Normal"},{"Text":"The quick-brown\n\n\nfox_jumps over\nthe"},{"Mode":"Normal"},{"Selection":{"start":[0,4],"end":[0,4]}},{"Mode":"Normal"},{"Text":"The quick-brown\n\n\nfox_jumps over\nthe"},{"Mode":"Normal"},{"Selection":{"start":[0,4],"end":[0,4]}},{"Mode":"Normal"},{"Text":"The quick-brown\n\n\nfox_jumps over\nthe"},{"Mode":"Normal"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Normal"},{"Text":"The quick-brown\n\n\nfox_jumps over\nthe"},{"Mode":"Normal"},{"Selection":{"start":[2,0],"end":[2,0]}},{"Mode":"Normal"},{"Text":"The quick-brown\n\n\nfox_jumps over\nthe"},{"Mode":"Normal"},{"Selection":{"start":[3,0],"end":[3,0]}},{"Mode":"Normal"},{"Text":"The quick-brown\n\n\nfox_jumps over\nthe"},{"Mode":"Normal"},{"Selection":{"start":[3,10],"end":[3,10]}},{"Mode":"Normal"}] \ No newline at end of file diff --git a/crates/vim/test_data/test_backspace.json b/crates/vim/test_data/test_backspace.json new file mode 100644 index 0000000000..d002dfa718 --- /dev/null +++ b/crates/vim/test_data/test_backspace.json @@ -0,0 +1 @@ +[{"Text":"The quick\nbrown"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"The quick\nbrown"},{"Mode":"Normal"},{"Selection":{"start":[0,4],"end":[0,4]}},{"Mode":"Normal"},{"Text":"The quick\nbrown"},{"Mode":"Normal"},{"Selection":{"start":[0,8],"end":[0,8]}},{"Mode":"Normal"}] \ No newline at end of file diff --git a/crates/vim/test_data/test_cc.json b/crates/vim/test_data/test_cc.json new file mode 100644 index 0000000000..67492d827e --- /dev/null +++ b/crates/vim/test_data/test_cc.json @@ -0,0 +1 @@ +[{"Text":""},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":""},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"\nbrown fox\njumps over"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"The quick\n\njumps over"},{"Mode":"Insert"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Insert"},{"Text":"The quick\nbrown fox\n"},{"Mode":"Insert"},{"Selection":{"start":[2,0],"end":[2,0]}},{"Mode":"Insert"},{"Text":"The quick\n\nbrown fox"},{"Mode":"Insert"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Insert"}] \ No newline at end of file diff --git a/crates/vim/test_data/test_change_sentence_object.json b/crates/vim/test_data/test_change_sentence_object.json new file mode 100644 index 0000000000..7827bc8a28 --- /dev/null +++ b/crates/vim/test_data/test_change_sentence_object.json @@ -0,0 +1 @@ +[{"Text":" Fox Jumps! Over the lazy."},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":" Fox Jumps! Over the lazy."},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":" Fox Jumps! Over the lazy."},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"The quick brown?Fox Jumps! Over the lazy."},{"Mode":"Insert"},{"Selection":{"start":[0,16],"end":[0,16]}},{"Mode":"Insert"},{"Text":"The quick brown? Over the lazy."},{"Mode":"Insert"},{"Selection":{"start":[0,17],"end":[0,17]}},{"Mode":"Insert"},{"Text":"The quick brown? Over the lazy."},{"Mode":"Insert"},{"Selection":{"start":[0,17],"end":[0,17]}},{"Mode":"Insert"},{"Text":"The quick brown? Over the lazy."},{"Mode":"Insert"},{"Selection":{"start":[0,17],"end":[0,17]}},{"Mode":"Insert"},{"Text":"The quick brown? Fox Jumps!Over the lazy."},{"Mode":"Insert"},{"Selection":{"start":[0,27],"end":[0,27]}},{"Mode":"Insert"},{"Text":"The quick brown? Fox Jumps! "},{"Mode":"Insert"},{"Selection":{"start":[0,28],"end":[0,28]}},{"Mode":"Insert"},{"Text":"The quick brown? Fox Jumps! "},{"Mode":"Insert"},{"Selection":{"start":[0,28],"end":[0,28]}},{"Mode":"Insert"},{"Text":"The quick brown? Fox Jumps! "},{"Mode":"Insert"},{"Selection":{"start":[0,28],"end":[0,28]}},{"Mode":"Insert"},{"Text":" The quick \nbrown fox jumps over\n"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":" The quick \nbrown fox jumps over\n"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":" The quick \nbrown fox jumps over\n"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":" The quick \nbrown fox jumps over\n"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":" The quick \nbrown fox jumps over\n"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"The quick brown \nfox jumps over\nthe lazy dog.The quick \nbrown fox jumps over\n"},{"Mode":"Insert"},{"Selection":{"start":[2,13],"end":[2,13]}},{"Mode":"Insert"},{"Text":"The quick brown \nfox jumps over\nthe lazy dog. \n"},{"Mode":"Insert"},{"Selection":{"start":[2,14],"end":[2,14]}},{"Mode":"Insert"},{"Text":"The quick brown \nfox jumps over\nthe lazy dog. \n"},{"Mode":"Insert"},{"Selection":{"start":[2,14],"end":[2,14]}},{"Mode":"Insert"},{"Text":" Brown fox jumps. "},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":" Brown fox jumps. "},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":" Brown fox jumps. "},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":" Brown fox jumps. "},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":" Brown fox jumps. "},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":" Brown fox jumps. "},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"The quick brown.)]'\" "},{"Mode":"Insert"},{"Selection":{"start":[0,21],"end":[0,21]}},{"Mode":"Insert"},{"Text":"The quick brown.)]'\" "},{"Mode":"Insert"},{"Selection":{"start":[0,21],"end":[0,21]}},{"Mode":"Insert"},{"Text":"The quick brown.)]'\" Brown fox jumps."},{"Mode":"Insert"},{"Selection":{"start":[0,37],"end":[0,37]}},{"Mode":"Insert"},{"Text":"Fox Jumps! Over the lazy."},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"Fox Jumps! Over the lazy."},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"Fox Jumps! Over the lazy."},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"The quick brown? Over the lazy."},{"Mode":"Insert"},{"Selection":{"start":[0,17],"end":[0,17]}},{"Mode":"Insert"},{"Text":"The quick brown? Over the lazy."},{"Mode":"Insert"},{"Selection":{"start":[0,17],"end":[0,17]}},{"Mode":"Insert"},{"Text":"The quick brown? Over the lazy."},{"Mode":"Insert"},{"Selection":{"start":[0,17],"end":[0,17]}},{"Mode":"Insert"},{"Text":"The quick brown? Fox Jumps!"},{"Mode":"Insert"},{"Selection":{"start":[0,27],"end":[0,27]}},{"Mode":"Insert"},{"Text":"The quick brown? Fox Jumps!"},{"Mode":"Insert"},{"Selection":{"start":[0,27],"end":[0,27]}},{"Mode":"Insert"},{"Text":"The quick brown? Fox Jumps!"},{"Mode":"Insert"},{"Selection":{"start":[0,27],"end":[0,27]}},{"Mode":"Insert"},{"Text":"The quick brown? Fox Jumps!"},{"Mode":"Insert"},{"Selection":{"start":[0,27],"end":[0,27]}},{"Mode":"Insert"},{"Text":"The quick \nbrown fox jumps over\n"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"The quick \nbrown fox jumps over\n"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"The quick \nbrown fox jumps over\n"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"The quick \nbrown fox jumps over\n"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"The quick \nbrown fox jumps over\n"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"The quick brown \nfox jumps over\nthe lazy dog.\n"},{"Mode":"Insert"},{"Selection":{"start":[2,13],"end":[2,13]}},{"Mode":"Insert"},{"Text":"The quick brown \nfox jumps over\nthe lazy dog.\n"},{"Mode":"Insert"},{"Selection":{"start":[2,13],"end":[2,13]}},{"Mode":"Insert"},{"Text":"The quick brown \nfox jumps over\nthe lazy dog.\n"},{"Mode":"Insert"},{"Selection":{"start":[2,13],"end":[2,13]}},{"Mode":"Insert"},{"Text":"Brown fox jumps. "},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"Brown fox jumps. "},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"Brown fox jumps. "},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"Brown fox jumps. "},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"Brown fox jumps. "},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"Brown fox jumps. "},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"The quick brown.)]'\" "},{"Mode":"Insert"},{"Selection":{"start":[0,21],"end":[0,21]}},{"Mode":"Insert"},{"Text":"The quick brown.)]'\" "},{"Mode":"Insert"},{"Selection":{"start":[0,21],"end":[0,21]}},{"Mode":"Insert"}] \ No newline at end of file diff --git a/crates/vim/test_data/test_change_surrounding_character_objects.json b/crates/vim/test_data/test_change_surrounding_character_objects.json new file mode 100644 index 0000000000..8a66f5b144 --- /dev/null +++ b/crates/vim/test_data/test_change_surrounding_character_objects.json @@ -0,0 +1 @@ +[{"Text":"Th)e ()qui(ck bro)wn(\n)fox jumps ov(er\nthe lazy d)o(g"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"Th)e ()qui(ck bro)wn(\n)fox jumps ov(er\nthe lazy d)o(g"},{"Mode":"Insert"},{"Selection":{"start":[0,6],"end":[0,6]}},{"Mode":"Insert"},{"Text":"Th)e ()qui(ck bro)wn(\n)fox jumps ov(er\nthe lazy d)o(g"},{"Mode":"Insert"},{"Selection":{"start":[0,6],"end":[0,6]}},{"Mode":"Insert"},{"Text":"Th)e ()qui(ck bro)wn(\n)fox jumps ov(er\nthe lazy d)o(g"},{"Mode":"Insert"},{"Selection":{"start":[0,6],"end":[0,6]}},{"Mode":"Insert"},{"Text":"Th)e ()qui()wn(\n)fox jumps ov(er\nthe lazy d)o(g"},{"Mode":"Insert"},{"Selection":{"start":[0,11],"end":[0,11]}},{"Mode":"Insert"},{"Text":"Th)e ()qui()wn(\n)fox jumps ov(er\nthe lazy d)o(g"},{"Mode":"Insert"},{"Selection":{"start":[0,11],"end":[0,11]}},{"Mode":"Insert"},{"Text":"Th)e ()qui()wn(\n)fox jumps ov(er\nthe lazy d)o(g"},{"Mode":"Insert"},{"Selection":{"start":[0,11],"end":[0,11]}},{"Mode":"Insert"},{"Text":"Th)e ()qui()wn(\n)fox jumps ov(er\nthe lazy d)o(g"},{"Mode":"Insert"},{"Selection":{"start":[0,11],"end":[0,11]}},{"Mode":"Insert"},{"Text":"Th)e ()qui(ck bro)wn(\n)fox jumps ov()o(g"},{"Mode":"Insert"},{"Selection":{"start":[1,14],"end":[1,14]}},{"Mode":"Insert"},{"Text":"Th)e ()qui(ck bro)wn(\n)fox jumps ov()o(g"},{"Mode":"Insert"},{"Selection":{"start":[1,14],"end":[1,14]}},{"Mode":"Insert"},{"Text":"Th)e ()qui(ck bro)wn(\n)fox jumps ov()o(g"},{"Mode":"Insert"},{"Selection":{"start":[1,14],"end":[1,14]}},{"Mode":"Insert"},{"Text":"Th)e ()qui(ck bro)wn(\n)fox jumps ov()o(g"},{"Mode":"Insert"},{"Selection":{"start":[1,14],"end":[1,14]}},{"Mode":"Insert"},{"Text":"Th)e ()qui(ck bro)wn(\n)fox jumps ov()o(g"},{"Mode":"Insert"},{"Selection":{"start":[1,14],"end":[1,14]}},{"Mode":"Insert"},{"Text":"Th)e ()qui(ck bro)wn(\n)fox jumps ov()o(g"},{"Mode":"Insert"},{"Selection":{"start":[1,14],"end":[1,14]}},{"Mode":"Insert"},{"Text":"Th)e ()qui(ck bro)wn(\n)fox jumps ov(er\nthe lazy d)o(g"},{"Mode":"Normal"},{"Selection":{"start":[2,11],"end":[2,11]}},{"Mode":"Normal"},{"Text":"Th)e ()qui(ck bro)wn(\n)fox jumps ov(er\nthe lazy d)o(g"},{"Mode":"Normal"},{"Selection":{"start":[2,12],"end":[2,12]}},{"Mode":"Normal"},{"Text":"Th)e ()qui(ck bro)wn(\n)fox jumps ov(er\nthe lazy d)o(g"},{"Mode":"Normal"},{"Selection":{"start":[2,13],"end":[2,13]}},{"Mode":"Normal"},{"Text":"Th)e ()qui(ck bro)wn(\n)fox jumps ov(er\nthe lazy d)o(g"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"Th)e qui(ck bro)wn(\n)fox jumps ov(er\nthe lazy d)o(g"},{"Mode":"Insert"},{"Selection":{"start":[0,5],"end":[0,5]}},{"Mode":"Insert"},{"Text":"Th)e qui(ck bro)wn(\n)fox jumps ov(er\nthe lazy d)o(g"},{"Mode":"Insert"},{"Selection":{"start":[0,5],"end":[0,5]}},{"Mode":"Insert"},{"Text":"Th)e qui(ck bro)wn(\n)fox jumps ov(er\nthe lazy d)o(g"},{"Mode":"Insert"},{"Selection":{"start":[0,5],"end":[0,5]}},{"Mode":"Insert"},{"Text":"Th)e ()quiwn(\n)fox jumps ov(er\nthe lazy d)o(g"},{"Mode":"Insert"},{"Selection":{"start":[0,10],"end":[0,10]}},{"Mode":"Insert"},{"Text":"Th)e ()quiwn(\n)fox jumps ov(er\nthe lazy d)o(g"},{"Mode":"Insert"},{"Selection":{"start":[0,10],"end":[0,10]}},{"Mode":"Insert"},{"Text":"Th)e ()quiwn(\n)fox jumps ov(er\nthe lazy d)o(g"},{"Mode":"Insert"},{"Selection":{"start":[0,10],"end":[0,10]}},{"Mode":"Insert"},{"Text":"Th)e ()quiwn(\n)fox jumps ov(er\nthe lazy d)o(g"},{"Mode":"Insert"},{"Selection":{"start":[0,10],"end":[0,10]}},{"Mode":"Insert"},{"Text":"Th)e ()qui(ck bro)wn(\n)fox jumps ovo(g"},{"Mode":"Insert"},{"Selection":{"start":[1,13],"end":[1,13]}},{"Mode":"Insert"},{"Text":"Th)e ()qui(ck bro)wn(\n)fox jumps ovo(g"},{"Mode":"Insert"},{"Selection":{"start":[1,13],"end":[1,13]}},{"Mode":"Insert"},{"Text":"Th)e ()qui(ck bro)wn(\n)fox jumps ovo(g"},{"Mode":"Insert"},{"Selection":{"start":[1,13],"end":[1,13]}},{"Mode":"Insert"},{"Text":"Th)e ()qui(ck bro)wn(\n)fox jumps ovo(g"},{"Mode":"Insert"},{"Selection":{"start":[1,13],"end":[1,13]}},{"Mode":"Insert"},{"Text":"Th)e ()qui(ck bro)wn(\n)fox jumps ovo(g"},{"Mode":"Insert"},{"Selection":{"start":[1,13],"end":[1,13]}},{"Mode":"Insert"},{"Text":"Th)e ()qui(ck bro)wn(\n)fox jumps ovo(g"},{"Mode":"Insert"},{"Selection":{"start":[1,13],"end":[1,13]}},{"Mode":"Insert"},{"Text":"Th)e ()qui(ck bro)wn(\n)fox jumps ov(er\nthe lazy d)o(g"},{"Mode":"Normal"},{"Selection":{"start":[2,11],"end":[2,11]}},{"Mode":"Normal"},{"Text":"Th)e ()qui(ck bro)wn(\n)fox jumps ov(er\nthe lazy d)o(g"},{"Mode":"Normal"},{"Selection":{"start":[2,12],"end":[2,12]}},{"Mode":"Normal"},{"Text":"Th)e ()qui(ck bro)wn(\n)fox jumps ov(er\nthe lazy d)o(g"},{"Mode":"Normal"},{"Selection":{"start":[2,13],"end":[2,13]}},{"Mode":"Normal"},{"Text":"Th]e []qui[ck bro]wn[\n]fox jumps ov[er\nthe lazy d]o[g"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"Th]e []qui[ck bro]wn[\n]fox jumps ov[er\nthe lazy d]o[g"},{"Mode":"Insert"},{"Selection":{"start":[0,6],"end":[0,6]}},{"Mode":"Insert"},{"Text":"Th]e []qui[ck bro]wn[\n]fox jumps ov[er\nthe lazy d]o[g"},{"Mode":"Insert"},{"Selection":{"start":[0,6],"end":[0,6]}},{"Mode":"Insert"},{"Text":"Th]e []qui[ck bro]wn[\n]fox jumps ov[er\nthe lazy d]o[g"},{"Mode":"Insert"},{"Selection":{"start":[0,6],"end":[0,6]}},{"Mode":"Insert"},{"Text":"Th]e []qui[]wn[\n]fox jumps ov[er\nthe lazy d]o[g"},{"Mode":"Insert"},{"Selection":{"start":[0,11],"end":[0,11]}},{"Mode":"Insert"},{"Text":"Th]e []qui[]wn[\n]fox jumps ov[er\nthe lazy d]o[g"},{"Mode":"Insert"},{"Selection":{"start":[0,11],"end":[0,11]}},{"Mode":"Insert"},{"Text":"Th]e []qui[]wn[\n]fox jumps ov[er\nthe lazy d]o[g"},{"Mode":"Insert"},{"Selection":{"start":[0,11],"end":[0,11]}},{"Mode":"Insert"},{"Text":"Th]e []qui[]wn[\n]fox jumps ov[er\nthe lazy d]o[g"},{"Mode":"Insert"},{"Selection":{"start":[0,11],"end":[0,11]}},{"Mode":"Insert"},{"Text":"Th]e []qui[ck bro]wn[\n]fox jumps ov[]o[g"},{"Mode":"Insert"},{"Selection":{"start":[1,14],"end":[1,14]}},{"Mode":"Insert"},{"Text":"Th]e []qui[ck bro]wn[\n]fox jumps ov[]o[g"},{"Mode":"Insert"},{"Selection":{"start":[1,14],"end":[1,14]}},{"Mode":"Insert"},{"Text":"Th]e []qui[ck bro]wn[\n]fox jumps ov[]o[g"},{"Mode":"Insert"},{"Selection":{"start":[1,14],"end":[1,14]}},{"Mode":"Insert"},{"Text":"Th]e []qui[ck bro]wn[\n]fox jumps ov[]o[g"},{"Mode":"Insert"},{"Selection":{"start":[1,14],"end":[1,14]}},{"Mode":"Insert"},{"Text":"Th]e []qui[ck bro]wn[\n]fox jumps ov[]o[g"},{"Mode":"Insert"},{"Selection":{"start":[1,14],"end":[1,14]}},{"Mode":"Insert"},{"Text":"Th]e []qui[ck bro]wn[\n]fox jumps ov[]o[g"},{"Mode":"Insert"},{"Selection":{"start":[1,14],"end":[1,14]}},{"Mode":"Insert"},{"Text":"Th]e []qui[ck bro]wn[\n]fox jumps ov[er\nthe lazy d]o[g"},{"Mode":"Normal"},{"Selection":{"start":[2,11],"end":[2,11]}},{"Mode":"Normal"},{"Text":"Th]e []qui[ck bro]wn[\n]fox jumps ov[er\nthe lazy d]o[g"},{"Mode":"Normal"},{"Selection":{"start":[2,12],"end":[2,12]}},{"Mode":"Normal"},{"Text":"Th]e []qui[ck bro]wn[\n]fox jumps ov[er\nthe lazy d]o[g"},{"Mode":"Normal"},{"Selection":{"start":[2,13],"end":[2,13]}},{"Mode":"Normal"},{"Text":"Th]e []qui[ck bro]wn[\n]fox jumps ov[er\nthe lazy d]o[g"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"Th]e qui[ck bro]wn[\n]fox jumps ov[er\nthe lazy d]o[g"},{"Mode":"Insert"},{"Selection":{"start":[0,5],"end":[0,5]}},{"Mode":"Insert"},{"Text":"Th]e qui[ck bro]wn[\n]fox jumps ov[er\nthe lazy d]o[g"},{"Mode":"Insert"},{"Selection":{"start":[0,5],"end":[0,5]}},{"Mode":"Insert"},{"Text":"Th]e qui[ck bro]wn[\n]fox jumps ov[er\nthe lazy d]o[g"},{"Mode":"Insert"},{"Selection":{"start":[0,5],"end":[0,5]}},{"Mode":"Insert"},{"Text":"Th]e []quiwn[\n]fox jumps ov[er\nthe lazy d]o[g"},{"Mode":"Insert"},{"Selection":{"start":[0,10],"end":[0,10]}},{"Mode":"Insert"},{"Text":"Th]e []quiwn[\n]fox jumps ov[er\nthe lazy d]o[g"},{"Mode":"Insert"},{"Selection":{"start":[0,10],"end":[0,10]}},{"Mode":"Insert"},{"Text":"Th]e []quiwn[\n]fox jumps ov[er\nthe lazy d]o[g"},{"Mode":"Insert"},{"Selection":{"start":[0,10],"end":[0,10]}},{"Mode":"Insert"},{"Text":"Th]e []quiwn[\n]fox jumps ov[er\nthe lazy d]o[g"},{"Mode":"Insert"},{"Selection":{"start":[0,10],"end":[0,10]}},{"Mode":"Insert"},{"Text":"Th]e []qui[ck bro]wn[\n]fox jumps ovo[g"},{"Mode":"Insert"},{"Selection":{"start":[1,13],"end":[1,13]}},{"Mode":"Insert"},{"Text":"Th]e []qui[ck bro]wn[\n]fox jumps ovo[g"},{"Mode":"Insert"},{"Selection":{"start":[1,13],"end":[1,13]}},{"Mode":"Insert"},{"Text":"Th]e []qui[ck bro]wn[\n]fox jumps ovo[g"},{"Mode":"Insert"},{"Selection":{"start":[1,13],"end":[1,13]}},{"Mode":"Insert"},{"Text":"Th]e []qui[ck bro]wn[\n]fox jumps ovo[g"},{"Mode":"Insert"},{"Selection":{"start":[1,13],"end":[1,13]}},{"Mode":"Insert"},{"Text":"Th]e []qui[ck bro]wn[\n]fox jumps ovo[g"},{"Mode":"Insert"},{"Selection":{"start":[1,13],"end":[1,13]}},{"Mode":"Insert"},{"Text":"Th]e []qui[ck bro]wn[\n]fox jumps ovo[g"},{"Mode":"Insert"},{"Selection":{"start":[1,13],"end":[1,13]}},{"Mode":"Insert"},{"Text":"Th]e []qui[ck bro]wn[\n]fox jumps ov[er\nthe lazy d]o[g"},{"Mode":"Normal"},{"Selection":{"start":[2,11],"end":[2,11]}},{"Mode":"Normal"},{"Text":"Th]e []qui[ck bro]wn[\n]fox jumps ov[er\nthe lazy d]o[g"},{"Mode":"Normal"},{"Selection":{"start":[2,12],"end":[2,12]}},{"Mode":"Normal"},{"Text":"Th]e []qui[ck bro]wn[\n]fox jumps ov[er\nthe lazy d]o[g"},{"Mode":"Normal"},{"Selection":{"start":[2,13],"end":[2,13]}},{"Mode":"Normal"},{"Text":"Th}e {}qui{ck bro}wn{\n}fox jumps ov{er\nthe lazy d}o{g"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"Th}e {}qui{ck bro}wn{\n}fox jumps ov{er\nthe lazy d}o{g"},{"Mode":"Insert"},{"Selection":{"start":[0,6],"end":[0,6]}},{"Mode":"Insert"},{"Text":"Th}e {}qui{ck bro}wn{\n}fox jumps ov{er\nthe lazy d}o{g"},{"Mode":"Insert"},{"Selection":{"start":[0,6],"end":[0,6]}},{"Mode":"Insert"},{"Text":"Th}e {}qui{ck bro}wn{\n}fox jumps ov{er\nthe lazy d}o{g"},{"Mode":"Insert"},{"Selection":{"start":[0,6],"end":[0,6]}},{"Mode":"Insert"},{"Text":"Th}e {}qui{}wn{\n}fox jumps ov{er\nthe lazy d}o{g"},{"Mode":"Insert"},{"Selection":{"start":[0,11],"end":[0,11]}},{"Mode":"Insert"},{"Text":"Th}e {}qui{}wn{\n}fox jumps ov{er\nthe lazy d}o{g"},{"Mode":"Insert"},{"Selection":{"start":[0,11],"end":[0,11]}},{"Mode":"Insert"},{"Text":"Th}e {}qui{}wn{\n}fox jumps ov{er\nthe lazy d}o{g"},{"Mode":"Insert"},{"Selection":{"start":[0,11],"end":[0,11]}},{"Mode":"Insert"},{"Text":"Th}e {}qui{}wn{\n}fox jumps ov{er\nthe lazy d}o{g"},{"Mode":"Insert"},{"Selection":{"start":[0,11],"end":[0,11]}},{"Mode":"Insert"},{"Text":"Th}e {}qui{ck bro}wn{\n}fox jumps ov{}o{g"},{"Mode":"Insert"},{"Selection":{"start":[1,14],"end":[1,14]}},{"Mode":"Insert"},{"Text":"Th}e {}qui{ck bro}wn{\n}fox jumps ov{}o{g"},{"Mode":"Insert"},{"Selection":{"start":[1,14],"end":[1,14]}},{"Mode":"Insert"},{"Text":"Th}e {}qui{ck bro}wn{\n}fox jumps ov{}o{g"},{"Mode":"Insert"},{"Selection":{"start":[1,14],"end":[1,14]}},{"Mode":"Insert"},{"Text":"Th}e {}qui{ck bro}wn{\n}fox jumps ov{}o{g"},{"Mode":"Insert"},{"Selection":{"start":[1,14],"end":[1,14]}},{"Mode":"Insert"},{"Text":"Th}e {}qui{ck bro}wn{\n}fox jumps ov{}o{g"},{"Mode":"Insert"},{"Selection":{"start":[1,14],"end":[1,14]}},{"Mode":"Insert"},{"Text":"Th}e {}qui{ck bro}wn{\n}fox jumps ov{}o{g"},{"Mode":"Insert"},{"Selection":{"start":[1,14],"end":[1,14]}},{"Mode":"Insert"},{"Text":"Th}e {}qui{ck bro}wn{\n}fox jumps ov{er\nthe lazy d}o{g"},{"Mode":"Normal"},{"Selection":{"start":[2,11],"end":[2,11]}},{"Mode":"Normal"},{"Text":"Th}e {}qui{ck bro}wn{\n}fox jumps ov{er\nthe lazy d}o{g"},{"Mode":"Normal"},{"Selection":{"start":[2,12],"end":[2,12]}},{"Mode":"Normal"},{"Text":"Th}e {}qui{ck bro}wn{\n}fox jumps ov{er\nthe lazy d}o{g"},{"Mode":"Normal"},{"Selection":{"start":[2,13],"end":[2,13]}},{"Mode":"Normal"},{"Text":"Th}e {}qui{ck bro}wn{\n}fox jumps ov{er\nthe lazy d}o{g"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"Th}e qui{ck bro}wn{\n}fox jumps ov{er\nthe lazy d}o{g"},{"Mode":"Insert"},{"Selection":{"start":[0,5],"end":[0,5]}},{"Mode":"Insert"},{"Text":"Th}e qui{ck bro}wn{\n}fox jumps ov{er\nthe lazy d}o{g"},{"Mode":"Insert"},{"Selection":{"start":[0,5],"end":[0,5]}},{"Mode":"Insert"},{"Text":"Th}e qui{ck bro}wn{\n}fox jumps ov{er\nthe lazy d}o{g"},{"Mode":"Insert"},{"Selection":{"start":[0,5],"end":[0,5]}},{"Mode":"Insert"},{"Text":"Th}e {}quiwn{\n}fox jumps ov{er\nthe lazy d}o{g"},{"Mode":"Insert"},{"Selection":{"start":[0,10],"end":[0,10]}},{"Mode":"Insert"},{"Text":"Th}e {}quiwn{\n}fox jumps ov{er\nthe lazy d}o{g"},{"Mode":"Insert"},{"Selection":{"start":[0,10],"end":[0,10]}},{"Mode":"Insert"},{"Text":"Th}e {}quiwn{\n}fox jumps ov{er\nthe lazy d}o{g"},{"Mode":"Insert"},{"Selection":{"start":[0,10],"end":[0,10]}},{"Mode":"Insert"},{"Text":"Th}e {}quiwn{\n}fox jumps ov{er\nthe lazy d}o{g"},{"Mode":"Insert"},{"Selection":{"start":[0,10],"end":[0,10]}},{"Mode":"Insert"},{"Text":"Th}e {}qui{ck bro}wn{\n}fox jumps ovo{g"},{"Mode":"Insert"},{"Selection":{"start":[1,13],"end":[1,13]}},{"Mode":"Insert"},{"Text":"Th}e {}qui{ck bro}wn{\n}fox jumps ovo{g"},{"Mode":"Insert"},{"Selection":{"start":[1,13],"end":[1,13]}},{"Mode":"Insert"},{"Text":"Th}e {}qui{ck bro}wn{\n}fox jumps ovo{g"},{"Mode":"Insert"},{"Selection":{"start":[1,13],"end":[1,13]}},{"Mode":"Insert"},{"Text":"Th}e {}qui{ck bro}wn{\n}fox jumps ovo{g"},{"Mode":"Insert"},{"Selection":{"start":[1,13],"end":[1,13]}},{"Mode":"Insert"},{"Text":"Th}e {}qui{ck bro}wn{\n}fox jumps ovo{g"},{"Mode":"Insert"},{"Selection":{"start":[1,13],"end":[1,13]}},{"Mode":"Insert"},{"Text":"Th}e {}qui{ck bro}wn{\n}fox jumps ovo{g"},{"Mode":"Insert"},{"Selection":{"start":[1,13],"end":[1,13]}},{"Mode":"Insert"},{"Text":"Th}e {}qui{ck bro}wn{\n}fox jumps ov{er\nthe lazy d}o{g"},{"Mode":"Normal"},{"Selection":{"start":[2,11],"end":[2,11]}},{"Mode":"Normal"},{"Text":"Th}e {}qui{ck bro}wn{\n}fox jumps ov{er\nthe lazy d}o{g"},{"Mode":"Normal"},{"Selection":{"start":[2,12],"end":[2,12]}},{"Mode":"Normal"},{"Text":"Th}e {}qui{ck bro}wn{\n}fox jumps ov{er\nthe lazy d}o{g"},{"Mode":"Normal"},{"Selection":{"start":[2,13],"end":[2,13]}},{"Mode":"Normal"},{"Text":"Th>e <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>qui<>wn<\n>fox jumps ovoe <>qui<>wn<\n>fox jumps ovoe <>qui<>wn<\n>fox jumps ovoe <>qui<>wn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ov<>oe <>quiwn<\n>fox jumps ov<>oe <>quiwn<\n>fox jumps ov<>oe <>quiwn<\n>fox jumps ov<>oe <>quiwn<\n>fox jumps ov<>oe <>quiwn<\n>fox jumps ov<>oe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe quiwn<\n>fox jumps ovoe quiwn<\n>fox jumps ovoe quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>qui<>wn<\n>fox jumps ovoe <>qui<>wn<\n>fox jumps ovoe <>qui<>wn<\n>fox jumps ovoe <>qui<>wn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ov<>oe <>quiwn<\n>fox jumps ov<>oe <>quiwn<\n>fox jumps ov<>oe <>quiwn<\n>fox jumps ov<>oe <>quiwn<\n>fox jumps ov<>oe <>quiwn<\n>fox jumps ov<>oe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe quiwn<\n>fox jumps ovoe quiwn<\n>fox jumps ovoe quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovoe <>quiwn<\n>fox jumps ovo