Detailed changes
@@ -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 }}
- ```
@@ -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 }}
@@ -8,4 +8,5 @@
/vendor/bin
/assets/themes/*.json
/assets/themes/internal/*.json
-/assets/themes/experiments/*.json
+/assets/themes/experiments/*.json
+**/venv
@@ -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",
@@ -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| {
@@ -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();
@@ -65,7 +65,6 @@ enum ContactEntry {
project_id: u64,
worktree_root_names: Vec<String>,
host_user_id: u64,
- is_host: bool,
is_last: bool,
},
IncomingRequest(Arc<User>),
@@ -181,6 +180,7 @@ impl ContactList {
let list_state = ListState::new(0, Orientation::Top, 1000., cx, move |this, ix, cx| {
let theme = cx.global::<Settings>().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,
@@ -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<Item = &str> {
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<Item = &str> {
+ (0..=display_row).into_iter().rev().flat_map(|row| {
+ self.blocks_snapshot
+ .chunks(row..row + 1, false, None)
+ .map(|h| h.text)
+ .collect::<Vec<_>>()
+ .into_iter()
+ .rev()
+ })
+ }
+
pub fn chunks(&self, display_rows: Range<u32>, 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<Item = char> + '_ {
- 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<Item = (char, DisplayPoint)> + '_ {
+ 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<Item = (char, DisplayPoint)> + '_ {
+ 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 {
@@ -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<Task<()>>,
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<Self>) {
+ if !self.show_scrollbars {
+ self.show_scrollbars = true;
+ cx.notify();
+ }
+
+ if cx.default_global::<ScrollbarAutoHide>().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<MultiBuffer>, cx: &mut ViewContext<Self>) {
cx.notify();
}
@@ -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},
@@ -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::<ScrollbarMouseHandlers>(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<u32, bool>,
highlighted_rows: Option<Range<u32>>,
line_number_layouts: Vec<Option<text_layout::Line>>,
blocks: Vec<BlockLayout>,
highlighted_ranges: Vec<(Range<DisplayPoint>, Color)>,
selections: Vec<(ReplicaId, Vec<SelectionLayout>)>,
+ scrollbar_row_range: Range<f32>,
+ show_scrollbars: bool,
+ max_row: u32,
context_menu: Option<(DisplayPoint, ElementBox)>,
diff_hunks: Vec<DiffHunk<u32>>,
code_actions_indicator: Option<(u32, ElementBox)>,
@@ -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};
@@ -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::*;
@@ -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::*;
@@ -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]
@@ -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 the current point is in the soft_wrap, skip comparing it
+ if point.column() < start_column {
+ continue;
+ }
- if let Some(prev_ch) = prev_ch {
- if is_boundary(prev_ch, ch) {
- boundary = Some(point);
- }
+ if let Some((prev_ch, prev_point)) = prev {
+ if is_boundary(ch, prev_ch) {
+ return prev_point;
}
+ }
- if ch == '\n' {
- break;
- }
+ prev = Some((ch, point));
+ }
+ DisplayPoint::zero()
+}
- prev_ch = Some(ch);
- *point.column_mut() += ch.len_utf8() as u32;
+/// 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;
}
+ }
- if let Some(boundary) = boundary {
- return boundary;
- } else if point.row() == 0 {
- return DisplayPoint::zero();
- } else {
- *point.row_mut() -= 1;
+ 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);
+ }
+ }
+
+ prev_ch = Some(ch);
+ }
+ 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' {
- *point.row_mut() += 1;
- *point.column_mut() = 0;
- } else {
- *point.column_mut() += ch.len_utf8() as u32;
+ break;
}
- prev_ch = Some(ch);
}
- map.clip_point(point, Bias::Right)
+
+ // 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 {
@@ -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,
-};
-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},
+ DisplayPoint, Editor, EditorMode, MultiBuffer,
};
-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<Editor>,
-}
-
-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<Output = ()> {
- self.editor.condition(self.cx, predicate)
- }
-
- pub fn editor<F, T>(&self, read: F) -> T
- where
- F: FnOnce(&Editor, &AppContext) -> T,
- {
- self.editor.read_with(self.cx, read)
- }
-
- pub fn update_editor<F, T>(&mut self, update: F) -> T
- where
- F: FnOnce(&mut Editor, &mut ViewContext<Editor>) -> T,
- {
- self.editor.update(self.cx, update)
- }
-
- pub fn multibuffer<F, T>(&self, read: F) -> T
- where
- F: FnOnce(&MultiBuffer, &AppContext) -> T,
- {
- self.editor(|editor, cx| read(editor.buffer().read(cx), cx))
- }
-
- pub fn update_multibuffer<F, T>(&mut self, update: F) -> T
- where
- F: FnOnce(&mut MultiBuffer, &mut ModelContext<MultiBuffer>) -> 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<F, T>(&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<F, T>(&mut self, update: F) -> T
- where
- F: FnOnce(&mut Buffer, &mut ModelContext<Buffer>) -> 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<const COUNT: usize>(&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<Range<usize>> {
- 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<language::Anchor> {
- 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<Tag: 'static>(&mut self, marked_text: &str) {
- let expected_ranges = self.ranges(marked_text);
- let actual_ranges: Vec<Range<usize>> = self.update_editor(|editor, cx| {
- let snapshot = editor.snapshot(cx);
- editor
- .background_highlights
- .get(&TypeId::of::<Tag>())
- .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<Tag: ?Sized + 'static>(&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<Range<usize>> = snapshot
- .highlight_ranges::<Tag>()
- .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<Range<usize>>) {
- 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<Range<usize>>,
- expected_marked_text: String,
- ) {
- let actual_selections = self
- .editor
- .read_with(self.cx, |editor, cx| editor.selections.all::<usize>(cx))
- .into_iter()
- .map(|s| {
- if s.reversed {
- s.end..s.start
- } else {
- s.start..s.end
- }
- })
- .collect::<Vec<_>>();
- 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<Workspace>,
- 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::<Editor>(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<usize>) -> 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<F, T>(&mut self, update: F) -> T
- where
- F: FnOnce(&mut Workspace, &mut ViewContext<Workspace>) -> T,
- {
- self.workspace.update(self.cx.cx, update)
- }
-
- pub fn handle_request<T, F, Fut>(
- &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<Output = Result<T::Result>>,
- {
- let url = self.buffer_lsp_url.clone();
- self.lsp.handle_request::<T, _, _>(move |params, cx| {
- let url = url.clone();
- handler(url, params, cx)
- })
- }
-
- pub fn notify<T: notification::Notification>(&self, params: T::Params) {
- self.lsp.notify::<T>(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
- }
-}
@@ -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<Workspace>,
+ 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::<Editor>(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<usize>) -> 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<F, T>(&mut self, update: F) -> T
+ where
+ F: FnOnce(&mut Workspace, &mut ViewContext<Workspace>) -> T,
+ {
+ self.workspace.update(self.cx.cx, update)
+ }
+
+ pub fn handle_request<T, F, Fut>(
+ &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<Output = Result<T::Result>>,
+ {
+ let url = self.buffer_lsp_url.clone();
+ self.lsp.handle_request::<T, _, _>(move |params, cx| {
+ let url = url.clone();
+ handler(url, params, cx)
+ })
+ }
+
+ pub fn notify<T: notification::Notification>(&self, params: T::Params) {
+ self.lsp.notify::<T>(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
+ }
+}
@@ -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<Editor>,
+}
+
+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<Output = ()> {
+ self.editor.condition(self.cx, predicate)
+ }
+
+ pub fn editor<F, T>(&self, read: F) -> T
+ where
+ F: FnOnce(&Editor, &AppContext) -> T,
+ {
+ self.editor.read_with(self.cx, read)
+ }
+
+ pub fn update_editor<F, T>(&mut self, update: F) -> T
+ where
+ F: FnOnce(&mut Editor, &mut ViewContext<Editor>) -> T,
+ {
+ self.editor.update(self.cx, update)
+ }
+
+ pub fn multibuffer<F, T>(&self, read: F) -> T
+ where
+ F: FnOnce(&MultiBuffer, &AppContext) -> T,
+ {
+ self.editor(|editor, cx| read(editor.buffer().read(cx), cx))
+ }
+
+ pub fn update_multibuffer<F, T>(&mut self, update: F) -> T
+ where
+ F: FnOnce(&mut MultiBuffer, &mut ModelContext<MultiBuffer>) -> 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<F, T>(&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<F, T>(&mut self, update: F) -> T
+ where
+ F: FnOnce(&mut Buffer, &mut ModelContext<Buffer>) -> 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<const COUNT: usize>(
+ &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<Range<usize>> {
+ 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<language::Anchor> {
+ 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<Tag: 'static>(&mut self, marked_text: &str) {
+ let expected_ranges = self.ranges(marked_text);
+ let actual_ranges: Vec<Range<usize>> = self.update_editor(|editor, cx| {
+ let snapshot = editor.snapshot(cx);
+ editor
+ .background_highlights
+ .get(&TypeId::of::<Tag>())
+ .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<Tag: ?Sized + 'static>(&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<Range<usize>> = snapshot
+ .highlight_ranges::<Tag>()
+ .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<Range<usize>>) {
+ 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<Range<usize>>,
+ expected_marked_text: String,
+ ) {
+ let actual_selections = self
+ .editor
+ .read_with(self.cx, |editor, cx| editor.selections.all::<usize>(cx))
+ .into_iter()
+ .map(|s| {
+ if s.reversed {
+ s.end..s.start
+ } else {
+ s.start..s.end
+ }
+ })
+ .collect::<Vec<_>>();
+ 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
+ }
+}
@@ -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"
@@ -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<RefCell<MutableAppContext>>);
#[derive(Clone)]
pub struct AsyncAppContext(Rc<RefCell<MutableAppContext>>);
-#[cfg(any(test, feature = "test-support"))]
-pub struct TestAppContext {
- cx: Rc<RefCell<MutableAppContext>>,
- foreground_platform: Rc<platform::test::ForegroundPlatform>,
- condition_duration: Option<Duration>,
-}
-
pub struct WindowInputHandler {
app: Rc<RefCell<MutableAppContext>>,
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::test::ForegroundPlatform>,
- platform: Arc<dyn Platform>,
- foreground: Rc<executor::Foreground>,
- background: Arc<executor::Background>,
- font_cache: Arc<FontCache>,
- leak_detector: Arc<Mutex<LeakDetector>>,
- 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<A: 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<A: 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<T, F>(&mut self, build_model: F) -> ModelHandle<T>
- where
- T: Entity,
- F: FnOnce(&mut ModelContext<T>) -> T,
- {
- self.cx.borrow_mut().add_model(build_model)
- }
-
- pub fn add_window<T, F>(&mut self, build_root_view: F) -> (usize, ViewHandle<T>)
- where
- T: View,
- F: FnOnce(&mut ViewContext<T>) -> 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<T, F>(
- &mut self,
- parent_handle: impl Into<AnyViewHandle>,
- build_view: F,
- ) -> ViewHandle<T>
- where
- T: View,
- F: FnOnce(&mut ViewContext<T>) -> T,
- {
- self.cx.borrow_mut().add_view(parent_handle, build_view)
- }
-
- pub fn window_ids(&self) -> Vec<usize> {
- self.cx.borrow().window_ids().collect()
- }
-
- pub fn root_view<T: View>(&self, window_id: usize) -> Option<ViewHandle<T>> {
- self.cx.borrow().root_view(window_id)
- }
-
- pub fn read<T, F: FnOnce(&AppContext) -> T>(&self, callback: F) -> T {
- callback(self.cx.borrow().as_ref())
- }
-
- pub fn update<T, F: FnOnce(&mut MutableAppContext) -> 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<F, V, T>(&mut self, handle: &ViewHandle<V>, f: F) -> T
- where
- F: FnOnce(&mut V, &mut RenderContext<V>) -> 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<FontCache> {
- self.cx.borrow().cx.font_cache.clone()
- }
-
- pub fn foreground_platform(&self) -> Rc<platform::test::ForegroundPlatform> {
- self.foreground_platform.clone()
- }
-
- pub fn platform(&self) -> Arc<dyn platform::Platform> {
- self.cx.borrow().cx.platform.clone()
- }
-
- pub fn foreground(&self) -> Rc<executor::Foreground> {
- self.cx.borrow().foreground().clone()
- }
-
- pub fn background(&self) -> Arc<executor::Background> {
- self.cx.borrow().background().clone()
- }
-
- pub fn spawn<F, Fut, T>(&self, f: F) -> Task<T>
- where
- F: FnOnce(AsyncAppContext) -> Fut,
- Fut: 'static + Future<Output = T>,
- 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<PathBuf>) {
- 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<String> {
- 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<usize>) {
- 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::<platform::test::Window>()
- .unwrap();
- handlers.insert(
- *window_id,
- mem::take(&mut window.active_status_change_handlers),
- );
- }
- };
- let mut handlers = handlers.into_iter().collect::<Vec<_>>();
- 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<Mutex<LeakDetector>> {
- 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<platform::test::Window> {
- 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::<platform::test::Window>()
- .unwrap();
- test_window
- })
- }
-
- pub fn set_condition_duration(&mut self, duration: Option<Duration>) {
- 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<F, Fut, T>(&self, f: F) -> Task<T>
where
@@ -894,60 +571,6 @@ impl ReadViewWith for AsyncAppContext {
}
}
-#[cfg(any(test, feature = "test-support"))]
-impl UpdateModel for TestAppContext {
- fn update_model<T: Entity, O>(
- &mut self,
- handle: &ModelHandle<T>,
- update: &mut dyn FnMut(&mut T, &mut ModelContext<T>) -> O,
- ) -> O {
- self.cx.borrow_mut().update_model(handle, update)
- }
-}
-
-#[cfg(any(test, feature = "test-support"))]
-impl ReadModelWith for TestAppContext {
- fn read_model_with<E: Entity, T>(
- &self,
- handle: &ModelHandle<E>,
- 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<T, S>(
- &mut self,
- handle: &ViewHandle<T>,
- update: &mut dyn FnMut(&mut T, &mut ViewContext<T>) -> 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<V, T>(
- &self,
- handle: &ViewHandle<V>,
- 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<T: Entity> ModelHandle<T> {
update(model, cx)
})
}
-
- #[cfg(any(test, feature = "test-support"))]
- pub fn next_notification(&self, cx: &TestAppContext) -> impl Future<Output = ()> {
- 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<Output = T::Event>
- 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<Output = ()> {
- 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<T: Entity> Clone for ModelHandle<T> {
@@ -4789,93 +4301,6 @@ impl<T: View> ViewHandle<T> {
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<Output = ()> {
- 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<Output = ()> {
- 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<T: View> Clone for ViewHandle<T> {
@@ -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<RefCell<MutableAppContext>>,
+ foreground_platform: Rc<platform::test::ForegroundPlatform>,
+ condition_duration: Option<Duration>,
+ pub function_name: String,
+ assertion_context: AssertionContextManager,
+}
+
+impl TestAppContext {
+ pub fn new(
+ foreground_platform: Rc<platform::test::ForegroundPlatform>,
+ platform: Arc<dyn Platform>,
+ foreground: Rc<executor::Foreground>,
+ background: Arc<executor::Background>,
+ font_cache: Arc<FontCache>,
+ leak_detector: Arc<Mutex<LeakDetector>>,
+ 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<A: 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<A: 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<T, F>(&mut self, build_model: F) -> ModelHandle<T>
+ where
+ T: Entity,
+ F: FnOnce(&mut ModelContext<T>) -> T,
+ {
+ self.cx.borrow_mut().add_model(build_model)
+ }
+
+ pub fn add_window<T, F>(&mut self, build_root_view: F) -> (usize, ViewHandle<T>)
+ where
+ T: View,
+ F: FnOnce(&mut ViewContext<T>) -> 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<T, F>(
+ &mut self,
+ parent_handle: impl Into<AnyViewHandle>,
+ build_view: F,
+ ) -> ViewHandle<T>
+ where
+ T: View,
+ F: FnOnce(&mut ViewContext<T>) -> T,
+ {
+ self.cx.borrow_mut().add_view(parent_handle, build_view)
+ }
+
+ pub fn window_ids(&self) -> Vec<usize> {
+ self.cx.borrow().window_ids().collect()
+ }
+
+ pub fn root_view<T: View>(&self, window_id: usize) -> Option<ViewHandle<T>> {
+ self.cx.borrow().root_view(window_id)
+ }
+
+ pub fn read<T, F: FnOnce(&AppContext) -> T>(&self, callback: F) -> T {
+ callback(self.cx.borrow().as_ref())
+ }
+
+ pub fn update<T, F: FnOnce(&mut MutableAppContext) -> 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<F, V, T>(&mut self, handle: &ViewHandle<V>, f: F) -> T
+ where
+ F: FnOnce(&mut V, &mut RenderContext<V>) -> 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<FontCache> {
+ self.cx.borrow().cx.font_cache.clone()
+ }
+
+ pub fn foreground_platform(&self) -> Rc<platform::test::ForegroundPlatform> {
+ self.foreground_platform.clone()
+ }
+
+ pub fn platform(&self) -> Arc<dyn platform::Platform> {
+ self.cx.borrow().cx.platform.clone()
+ }
+
+ pub fn foreground(&self) -> Rc<executor::Foreground> {
+ self.cx.borrow().foreground().clone()
+ }
+
+ pub fn background(&self) -> Arc<executor::Background> {
+ self.cx.borrow().background().clone()
+ }
+
+ pub fn spawn<F, Fut, T>(&self, f: F) -> Task<T>
+ where
+ F: FnOnce(AsyncAppContext) -> Fut,
+ Fut: 'static + Future<Output = T>,
+ 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<PathBuf>) {
+ 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<String> {
+ 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<usize>) {
+ 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::<platform::test::Window>()
+ .unwrap();
+ handlers.insert(
+ *window_id,
+ mem::take(&mut window.active_status_change_handlers),
+ );
+ }
+ };
+ let mut handlers = handlers.into_iter().collect::<Vec<_>>();
+ 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<Mutex<LeakDetector>> {
+ 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<platform::test::Window> {
+ 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::<platform::test::Window>()
+ .unwrap();
+ test_window
+ })
+ }
+
+ pub fn set_condition_duration(&mut self, duration: Option<Duration>) {
+ 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<T: Entity, O>(
+ &mut self,
+ handle: &ModelHandle<T>,
+ update: &mut dyn FnMut(&mut T, &mut ModelContext<T>) -> O,
+ ) -> O {
+ self.cx.borrow_mut().update_model(handle, update)
+ }
+}
+
+impl ReadModelWith for TestAppContext {
+ fn read_model_with<E: Entity, T>(
+ &self,
+ handle: &ModelHandle<E>,
+ 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<T, S>(
+ &mut self,
+ handle: &ViewHandle<T>,
+ update: &mut dyn FnMut(&mut T, &mut ViewContext<T>) -> S,
+ ) -> S
+ where
+ T: View,
+ {
+ self.cx.borrow_mut().update_view(handle, update)
+ }
+}
+
+impl ReadViewWith for TestAppContext {
+ fn read_view_with<V, T>(
+ &self,
+ handle: &ViewHandle<V>,
+ 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<T: Entity> ModelHandle<T> {
+ pub fn next_notification(&self, cx: &TestAppContext) -> impl Future<Output = ()> {
+ 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<Output = T::Event>
+ 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<Output = ()> {
+ 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<T: View> ViewHandle<T> {
+ pub fn next_notification(&self, cx: &TestAppContext) -> impl Future<Output = ()> {
+ 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<Output = ()> {
+ 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<AtomicUsize>,
+ contexts: Arc<RwLock<BTreeMap<usize, String>>>,
+}
+
+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);
+ }
+}
@@ -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;
@@ -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];
@@ -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
}
@@ -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(
@@ -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(),
);
}
}
@@ -554,6 +554,15 @@ pub struct Editor {
pub link_definition: HighlightStyle,
pub composition_mark: HighlightStyle,
pub jump_icon: Interactive<IconButton>,
+ 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)]
@@ -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"] }
@@ -26,7 +26,7 @@ fn normal_before(_: &mut Workspace, _: &NormalBefore, cx: &mut ViewContext<Works
#[cfg(test)]
mod test {
- use crate::{state::Mode, vim_test_context::VimTestContext};
+ use crate::{state::Mode, test::VimTestContext};
#[gpui::test]
async fn test_enter_and_exit_insert_mode(cx: &mut gpui::TestAppContext) {
@@ -18,6 +18,7 @@ use crate::{
#[derive(Copy, Clone, Debug)]
pub enum Motion {
Left,
+ Backspace,
Down,
Up,
Right,
@@ -58,6 +59,7 @@ actions!(
vim,
[
Left,
+ Backspace,
Down,
Up,
Right,
@@ -74,6 +76,7 @@ impl_actions!(vim, [NextWordStart, NextWordEnd, PreviousWordStart]);
pub fn init(cx: &mut MutableAppContext) {
cx.add_action(|_: &mut Workspace, _: &Left, cx: _| motion(Motion::Left, cx));
+ cx.add_action(|_: &mut Workspace, _: &Backspace, cx: _| motion(Motion::Backspace, cx));
cx.add_action(|_: &mut Workspace, _: &Down, cx: _| motion(Motion::Down, cx));
cx.add_action(|_: &mut Workspace, _: &Up, cx: _| motion(Motion::Up, cx));
cx.add_action(|_: &mut Workspace, _: &Right, cx: _| motion(Motion::Right, cx));
@@ -106,19 +109,21 @@ pub fn init(cx: &mut MutableAppContext) {
);
}
-fn motion(motion: Motion, cx: &mut MutableAppContext) {
- Vim::update(cx, |vim, cx| {
- if let Some(Operator::Namespace(_)) = vim.active_operator() {
- vim.pop_operator(cx);
- }
- });
+pub(crate) fn motion(motion: Motion, cx: &mut MutableAppContext) {
+ if let Some(Operator::Namespace(_)) = Vim::read(cx).active_operator() {
+ Vim::update(cx, |vim, cx| vim.pop_operator(cx));
+ }
+
+ let times = Vim::update(cx, |vim, cx| vim.pop_number_operator(cx));
+ let operator = Vim::read(cx).active_operator();
match Vim::read(cx).state.mode {
- Mode::Normal => 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<DisplayPoint>,
+ 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 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;
+ }
+ }
+ point
+}
+
+fn backspace(map: &DisplaySnapshot, mut point: DisplayPoint, times: usize) -> DisplayPoint {
+ for _ in 0..times {
+ point = movement::left(map, point);
+ }
+ point
}
-fn right(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint {
- *point.column_mut() += 1;
- map.clip_point(point, Bias::Right)
+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 next_word_start(
+fn up(
map: &DisplaySnapshot,
- point: DisplayPoint,
+ 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 {
- 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;
- }
- found
- })
+ 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);
-
- 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);
+ 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_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);
-
- (left_kind != right_kind && !right.is_whitespace()) || left == '\n'
- });
+ 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'
+ });
+ }
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)
}
@@ -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,
+ operator: Option<Operator>,
+ times: usize,
+ cx: &mut MutableAppContext,
+) {
+ Vim::update(cx, |vim, cx| {
+ 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
+ }
+ }
+ });
}
-pub fn normal_motion(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() {
- None => move_cursor(vim, motion, cx),
- Some(Operator::Namespace(_)) => {
- // Can't do anything for a namespace operator. Ignoring
+ 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
}
- 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) {
+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<Workspa
vim.update_active_editor(cx, |editor, cx| {
editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
s.move_cursors_with(|map, cursor, goal| {
- Motion::Right.move_point(map, cursor, goal)
+ Motion::Right.move_point(map, cursor, goal, 1)
});
});
});
@@ -113,7 +144,7 @@ fn insert_first_non_whitespace(
vim.update_active_editor(cx, |editor, cx| {
editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
s.move_cursors_with(|map, cursor, goal| {
- Motion::FirstNonWhitespace.move_point(map, cursor, goal)
+ Motion::FirstNonWhitespace.move_point(map, cursor, goal, 1)
});
});
});
@@ -126,7 +157,7 @@ fn insert_end_of_line(_: &mut Workspace, _: &InsertEndOfLine, cx: &mut ViewConte
vim.update_active_editor(cx, |editor, cx| {
editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
s.move_cursors_with(|map, cursor, goal| {
- Motion::EndOfLine.move_point(map, cursor, goal)
+ Motion::EndOfLine.move_point(map, cursor, goal, 1)
});
});
});
@@ -186,7 +217,7 @@ fn insert_line_below(_: &mut Workspace, _: &InsertLineBelow, cx: &mut ViewContex
});
editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
s.move_cursors_with(|map, cursor, goal| {
- Motion::EndOfLine.move_point(map, cursor, goal)
+ Motion::EndOfLine.move_point(map, cursor, goal, 1)
});
});
editor.edit_with_autoindent(edits, cx);
@@ -224,7 +255,18 @@ fn paste(_: &mut Workspace, _: &Paste, cx: &mut ViewContext<Workspace>) {
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<usize, NewPosition> = 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<Workspace>) {
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<Workspace>) {
.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<Workspace>) {
});
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<Workspace>) {
#[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(
- indoc! {"
- The หquick
- brown"},
- 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"},
- );
+ let mut cx = NeovimBackedTestContext::new(cx).await;
+ cx.assert_binding_matches_all(
+ ["$"],
+ indoc! {"
+ หThe qหuicหk
+ หbrowหn"},
+ )
+ .await;
+ cx.assert_binding_matches_all(
+ ["0"],
+ indoc! {"
+ ห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"},
- indoc! {"
- The qหuick
-
- brown fox jumps
- over the lazy dog"},
- );
- cx.assert(
- indoc! {"
- The qหuick
-
- 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 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! {"
- ห
- The quick"},
- indoc! {"
+ brown fox"})
+ .await;
+ cx.assert(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! {"
- The quick
- ห
- brown fox"},
- indoc! {"
+ brown fox"})
+ .await;
+ cx.assert(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! {"
- The quick
- ห
- brown fox
- jumps over"},
- );
- cx.assert(
- indoc! {"
+ brown หfox
+ jumps หover"})
+ .await;
+ 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! {"
- The quick brown
- the lazy dog
- หfox jumps over"},
- Mode::Normal,
- );
+ cx.simulate_shared_keystroke("p").await;
+ cx.assert_state_matches().await;
- cx.set_state(
- indoc! {"
+ cx.set_shared_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;
+ }
}
}
@@ -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;
-
-#[derive(Clone, Deserialize, PartialEq)]
-#[serde(rename_all = "camelCase")]
-struct ChangeWord {
- #[serde(default)]
- ignore_punctuation: bool,
-}
+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;
-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<Workspace>,
+fn expand_changed_word_selection(
+ map: &DisplaySnapshot,
+ selection: &mut Selection<DisplayPoint>,
+ 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;
- }
-
- 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'
- });
- });
- });
- copy_selections_content(editor, false, cx);
- editor.insert("", cx);
- });
- });
- vim.switch_mode(Mode::Insert, false, cx);
+ if times > 1 {
+ Motion::NextWordStart { ignore_punctuation }.expand_selection(
+ map,
+ selection,
+ times - 1,
+ false,
+ );
+ }
+
+ if times == 1 && selection.end.column() == map.line_len(selection.end.row()) {
+ return;
+ }
+
+ 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;
+ }
+ }
}
@@ -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"]);
@@ -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);
+ });
+ });
+ });
+ });
+}
@@ -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<Range<DisplayPoint>> {
+ 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<DisplayPoint>,
+ 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<Range<DisplayPoint>> {
+ // 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<Range<DisplayPoint>> {
+ 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<Range<DisplayPoint>> {
+ 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<Range<DisplayPoint>> {
+ // 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<Range<DisplayPoint>> {
+ 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<DisplayPoint>,
+ stop_at_newline: bool,
+) -> Range<DisplayPoint> {
+ 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<Range<DisplayPoint>> {
+ 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;
+ }
+ }
+}
@@ -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();
@@ -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::<BufferSearchBar>()
+ .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");
+ })
+}
@@ -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<const NEW_COUNT: usize>(
+ 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
+ }
+}
@@ -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<String, Option<HashSet<String>>>,
+ 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<const COUNT: usize>(
+ &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::<language::Point>(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<const COUNT: usize>(
+ &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<const COUNT: usize>(
+ &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<const COUNT: usize>(
+ 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;
+ }
+}
@@ -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<Mode>),
+}
+
+pub struct NeovimConnection {
+ data: VecDeque<NeovimData>,
+ #[cfg(feature = "neovim")]
+ test_case_id: String,
+ #[cfg(feature = "neovim")]
+ nvim: Neovim<nvim_rs::compat::tokio::Compat<ChildStdin>>,
+ #[cfg(feature = "neovim")]
+ _join_handle: JoinHandle<Result<(), Box<LoopError>>>,
+ #[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<Point>, 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::<Vec<_>>();
+
+ nvim_buffer
+ .set_lines(0, -1, false, lines)
+ .await
+ .expect("Could not set nvim buffer text");
+
+ self.nvim
+ .input("<escape>")
+ .await
+ .expect("Could not send escape to nvim");
+ self.nvim
+ .input("<escape>")
+ .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<Point>, _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<Point> {
+ let cursor_row: u32 = self
+ .nvim
+ .command_output("echo line('.')")
+ .await
+ .unwrap()
+ .parse::<u32>()
+ .unwrap()
+ - 1; // Neovim rows start at 1
+ let cursor_col: u32 = self
+ .nvim
+ .command_output("echo col('.')")
+ .await
+ .unwrap()
+ .parse::<u32>()
+ .unwrap()
+ - 1; // Neovim columns start at 1
+
+ let (start, end) = if let Some(Mode::Visual { .. }) = self.mode().await {
+ self.nvim
+ .input("<escape>")
+ .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<Point> {
+ // 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<Mode> {
+ 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<Mode> {
+ 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<NeovimData> {
+ 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<nvim_rs::compat::tokio::Compat<ChildStdin>>;
+
+ 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<ChildStdin>;
+
+ async fn handle_request(
+ &self,
+ _event_name: String,
+ _arguments: Vec<Value>,
+ _neovim: Neovim<Self::Writer>,
+ ) -> Result<Value, Value> {
+ unimplemented!();
+ }
+
+ async fn handle_notify(
+ &self,
+ _event_name: String,
+ _arguments: Vec<Value>,
+ _neovim: Neovim<Self::Writer>,
+ ) {
+ }
+}
@@ -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<const NEW_COUNT: usize>(
+ 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
+ }
+}
@@ -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<Workspace>,
@@ -117,18 +119,18 @@ impl<'a> VimTestContext<'a> {
.read(|cx| cx.global::<Vim>().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<const COUNT: usize>(
@@ -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<const COUNT: usize>(
@@ -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<const NEW_COUNT: usize>(
- 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
- }
-}
@@ -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::<BufferSearchBar>()
- .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");
- })
- }
-}
@@ -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<Workspace>) {
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<Workspace>
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(
+ let mut cx = NeovimBackedTestContext::new(cx).await;
+
+ 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! {"
- The หver
- the lazy dog"},
- );
+ )
+ .await;
// 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"});
+ cx.simulate_shared_keystrokes(["j", "p"]).await;
+ cx.assert_state_matches().await;
- cx.assert(
- indoc! {"
- The quick brown
- fox jumps over
- the หlazy dog"},
- indoc! {"
- The quick brown
+ let mut cx = cx.binding(["v", "w", "j", "x"]);
+ cx.assert_all(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"},
- );
+ the หlazy dog"})
+ .await;
let mut cx = cx.binding(["v", "b", "k", "x"]);
- cx.assert(
- indoc! {"
+ 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_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,
);
@@ -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"}]
@@ -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"}]
@@ -0,0 +1 @@
@@ -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"}]
@@ -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"}]
@@ -0,0 +1 @@
@@ -0,0 +1 @@
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+[{"Text":""},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":""},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"brown fox\njumps over"},{"Mode":"Normal"},{"Selection":{"start":[0,5],"end":[0,5]}},{"Mode":"Normal"},{"Text":"The quick\njumps over"},{"Mode":"Normal"},{"Selection":{"start":[1,6],"end":[1,6]}},{"Mode":"Normal"},{"Text":"The quick\nbrown fox"},{"Mode":"Normal"},{"Selection":{"start":[1,6],"end":[1,6]}},{"Mode":"Normal"},{"Text":"The quick\nbrown fox"},{"Mode":"Normal"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Normal"}]
@@ -0,0 +1 @@
+[{"Text":"Test"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"est"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"Tst"},{"Mode":"Normal"},{"Selection":{"start":[0,1],"end":[0,1]}},{"Mode":"Normal"},{"Text":"Tet"},{"Mode":"Normal"},{"Selection":{"start":[0,2],"end":[0,2]}},{"Mode":"Normal"},{"Text":"Test\ntest"},{"Mode":"Normal"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Normal"}]
@@ -0,0 +1 @@
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+[{"Text":"The q\nbrown fox"},{"Mode":"Normal"},{"Selection":{"start":[0,4],"end":[0,4]}},{"Mode":"Normal"},{"Text":"The quick\n\nbrown fox"},{"Mode":"Normal"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Normal"}]
@@ -0,0 +1 @@
@@ -0,0 +1 @@
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+[{"Text":"The quick\n\nbrown fox jumps\nover the lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[0,5],"end":[0,5]}},{"Mode":"Normal"},{"Text":"The quick\n\nbrown fox jumps\nover the lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[0,5],"end":[0,5]}},{"Mode":"Normal"},{"Text":"The quick\n\nbrown fox jumps\nover the lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[0,8],"end":[0,8]}},{"Mode":"Normal"},{"Text":"\n\nbrown fox jumps\nover the lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"\n\nbrown fox jumps\nover the lazydog"},{"Mode":"Normal"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Normal"}]
@@ -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":[1,0],"end":[1,0]}},{"Mode":"Normal"}]
@@ -0,0 +1 @@
+[{"Text":"\nThe quick\nbrown fox "},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"\nThe quick\nbrown fox "},{"Mode":"Insert"},{"Selection":{"start":[1,9],"end":[1,9]}},{"Mode":"Insert"},{"Text":"\nThe quick\nbrown fox "},{"Mode":"Insert"},{"Selection":{"start":[2,10],"end":[2,10]}},{"Mode":"Insert"}]
@@ -0,0 +1 @@
+[{"Text":"The quick"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":" The quick"},{"Mode":"Insert"},{"Selection":{"start":[0,1],"end":[0,1]}},{"Mode":"Insert"},{"Text":""},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"The quick\nbrown fox"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"\nThe quick"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"}]
@@ -0,0 +1 @@
+[{"Text":"\n"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"\nThe quick"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"\nThe quick\nbrown fox\njumps over"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"The quick\n\nbrown fox\njumps over"},{"Mode":"Insert"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Insert"},{"Text":"The quick\nbrown fox\n\njumps over"},{"Mode":"Insert"},{"Selection":{"start":[2,0],"end":[2,0]}},{"Mode":"Insert"},{"Text":"The quick\n\n\nbrown fox"},{"Mode":"Insert"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Insert"}]
@@ -0,0 +1 @@
+[{"Text":"The quick brown\nfox jumps"},{"Mode":"Normal"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Normal"},{"Text":"The quick brown\nfox jumps"},{"Mode":"Normal"},{"Selection":{"start":[1,5],"end":[1,5]}},{"Mode":"Normal"},{"Text":"The quick brown\nfox jumps"},{"Mode":"Normal"},{"Selection":{"start":[1,8],"end":[1,8]}},{"Mode":"Normal"},{"Text":"The quick brown\nfox jumps"},{"Mode":"Normal"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Normal"}]
@@ -0,0 +1 @@
+[{"Text":"The quick\n\nbrown fox jumps\nover the lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[3,4],"end":[3,4]}},{"Mode":"Normal"},{"Text":"The quick\n\nbrown fox jumps\nover the lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[3,4],"end":[3,4]}},{"Mode":"Normal"},{"Text":"The quick\n\nbrown fox jumps\nover the lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[3,16],"end":[3,16]}},{"Mode":"Normal"},{"Text":"The quick\n\nbrown"},{"Mode":"Normal"},{"Selection":{"start":[2,4],"end":[2,4]}},{"Mode":"Normal"},{"Text":"The quick\n\n"},{"Mode":"Normal"},{"Selection":{"start":[2,0],"end":[2,0]}},{"Mode":"Normal"}]
@@ -0,0 +1 @@
+[{"Text":"The quick"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":" The quick"},{"Mode":"Normal"},{"Selection":{"start":[0,1],"end":[0,1]}},{"Mode":"Normal"},{"Text":""},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"The quick\nbrown fox"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"\nThe quick"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":" \nThe quick"},{"Mode":"Normal"},{"Selection":{"start":[0,3],"end":[0,3]}},{"Mode":"Normal"}]
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+[{"Text":"The quick\nbrown fox jumps"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"The quick\nbrown fox jumps"},{"Mode":"Normal"},{"Selection":{"start":[0,5],"end":[0,5]}},{"Mode":"Normal"},{"Text":"The quick\nbrown fox jumps"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"The quick\nbrown fox jumps"},{"Mode":"Normal"},{"Selection":{"start":[0,7],"end":[0,7]}},{"Mode":"Normal"},{"Text":"The quick\nbrown fox jumps"},{"Mode":"Normal"},{"Selection":{"start":[0,8],"end":[0,8]}},{"Mode":"Normal"}]
@@ -0,0 +1 @@
+[{"Text":"The quick\nbrown"},{"Mode":"Normal"},{"Selection":{"start":[0,1],"end":[0,1]}},{"Mode":"Normal"},{"Text":"The quick\nbrown"},{"Mode":"Normal"},{"Selection":{"start":[0,6],"end":[0,6]}},{"Mode":"Normal"},{"Text":"The quick\nbrown"},{"Mode":"Normal"},{"Selection":{"start":[0,8],"end":[0,8]}},{"Mode":"Normal"},{"Text":"The quick\nbrown"},{"Mode":"Normal"},{"Selection":{"start":[1,1],"end":[1,1]}},{"Mode":"Normal"},{"Text":"The quick\nbrown"},{"Mode":"Normal"},{"Selection":{"start":[1,4],"end":[1,4]}},{"Mode":"Normal"}]
@@ -0,0 +1 @@
+[{"Text":""},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"test"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"}]
@@ -0,0 +1 @@
+[{"Text":"\n"},{"Mode":"Insert"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Insert"},{"Text":"The quick\n"},{"Mode":"Insert"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Insert"},{"Text":"The quick\n\nbrown fox\njumps over"},{"Mode":"Insert"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Insert"},{"Text":"The quick\nbrown fox\n\njumps over"},{"Mode":"Insert"},{"Selection":{"start":[2,0],"end":[2,0]}},{"Mode":"Insert"},{"Text":"The quick\nbrown fox\njumps over\n"},{"Mode":"Insert"},{"Selection":{"start":[3,0],"end":[3,0]}},{"Mode":"Insert"},{"Text":"The quick\n\n\nbrown fox"},{"Mode":"Insert"},{"Selection":{"start":[2,0],"end":[2,0]}},{"Mode":"Insert"},{"Text":"fn test() {\n println!();\n \n}\n"},{"Mode":"Insert"},{"Selection":{"start":[2,4],"end":[2,4]}},{"Mode":"Insert"},{"Text":"fn test() {\n\n println!();\n}"},{"Mode":"Insert"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Insert"}]
@@ -0,0 +1 @@
+[{"Text":"The quick brown\nthe lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[1,6],"end":[1,6]}},{"Mode":"Normal"},{"Text":"The quick brown\nthe lazy dog\nfox jumps over"},{"Mode":"Normal"},{"Selection":{"start":[2,0],"end":[2,0]}},{"Mode":"Normal"},{"Text":"The quick brown\nfox jumps overjumps o\nthe lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[1,20],"end":[1,20]}},{"Mode":"Normal"}]
@@ -0,0 +1 @@
@@ -0,0 +1 @@
@@ -0,0 +1 @@
@@ -0,0 +1 @@
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+[{"Text":"The quick "},{"Mode":"Insert"},{"Selection":{"start":[0,10],"end":[0,10]}},{"Mode":"Insert"},{"Text":"The ver\nthe lazy dog"},{"Mode":"Insert"},{"Selection":{"start":[0,4],"end":[0,4]}},{"Mode":"Insert"},{"Text":"The quick brown\nfox jumps he lazy dog"},{"Mode":"Insert"},{"Selection":{"start":[1,10],"end":[1,10]}},{"Mode":"Insert"},{"Text":"The quick brown\nfox jumps over\nthe og"},{"Mode":"Insert"},{"Selection":{"start":[2,4],"end":[2,4]}},{"Mode":"Insert"},{"Text":"uick brown\nfox jumps over\nthe lazy dog"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"The ver\nthe lazy dog"},{"Mode":"Insert"},{"Selection":{"start":[0,4],"end":[0,4]}},{"Mode":"Insert"},{"Text":"The quick brown\nazy dog"},{"Mode":"Insert"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Insert"}]
@@ -0,0 +1 @@
+[{"Text":"The quick "},{"Mode":"Normal"},{"Selection":{"start":[0,9],"end":[0,9]}},{"Mode":"Normal"},{"Text":"The ver\nthe lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[0,4],"end":[0,4]}},{"Mode":"Normal"},{"Text":"The ver\nthe lquick brown\nfox jumps oazy dog"},{"Mode":"Normal"},{"Selection":{"start":[1,5],"end":[1,5]}},{"Mode":"Normal"},{"Text":"The ver\nthe lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[0,4],"end":[0,4]}},{"Mode":"Normal"},{"Text":"The quick brown\nfox jumps over\nthe og"},{"Mode":"Normal"},{"Selection":{"start":[2,4],"end":[2,4]}},{"Mode":"Normal"},{"Text":"uick brown\nfox jumps over\nthe lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"The ver\nthe lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[0,4],"end":[0,4]}},{"Mode":"Normal"},{"Text":"The quick brown\nazy dog"},{"Mode":"Normal"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Normal"}]
@@ -0,0 +1 @@
+[{"Text":"\nfox jumps over\nthe lazy dog"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"\nfox jumps over\nThe quick brown\nthe lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[2,0],"end":[2,0]}},{"Mode":"Normal"},{"Text":"The quick brown\n\nthe lazy dog"},{"Mode":"Insert"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Insert"},{"Text":"The quick brown\nfox jumps over\n"},{"Mode":"Insert"},{"Selection":{"start":[2,0],"end":[2,0]}},{"Mode":"Insert"},{"Text":"\nthe lazy dog"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"\nthe lazy dog\nThe quick brown\nfox jumps over"},{"Mode":"Normal"},{"Selection":{"start":[2,0],"end":[2,0]}},{"Mode":"Normal"},{"Text":"The quick brown\n"},{"Mode":"Insert"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Insert"},{"Text":"The quick brown\nfox jumps over\n"},{"Mode":"Insert"},{"Selection":{"start":[2,0],"end":[2,0]}},{"Mode":"Insert"}]
@@ -0,0 +1 @@
+[{"Text":"fox jumps over\nthe lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[0,6],"end":[0,6]}},{"Mode":"Normal"},{"Text":"fox jumps over\nThe quick brown\nthe lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Normal"},{"Text":"The quick brown\nthe lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[1,6],"end":[1,6]}},{"Mode":"Normal"},{"Text":"The quick brown\nfox jumps over"},{"Mode":"Normal"},{"Selection":{"start":[1,6],"end":[1,6]}},{"Mode":"Normal"},{"Text":"the lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[0,6],"end":[0,6]}},{"Mode":"Normal"},{"Text":"the lazy dog\nThe quick brown\nfox jumps over"},{"Mode":"Normal"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Normal"},{"Text":"The quick brown"},{"Mode":"Normal"},{"Selection":{"start":[0,6],"end":[0,6]}},{"Mode":"Normal"},{"Text":"The quick brown\nfox jumps over"},{"Mode":"Normal"},{"Selection":{"start":[1,6],"end":[1,6]}},{"Mode":"Normal"}]
@@ -0,0 +1 @@
@@ -0,0 +1 @@
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+[{"Text":"est"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"Tet"},{"Mode":"Normal"},{"Selection":{"start":[0,2],"end":[0,2]}},{"Mode":"Normal"},{"Text":"Tes"},{"Mode":"Normal"},{"Selection":{"start":[0,2],"end":[0,2]}},{"Mode":"Normal"},{"Text":"Tes\ntest"},{"Mode":"Normal"},{"Selection":{"start":[0,2],"end":[0,2]}},{"Mode":"Normal"}]
@@ -0,0 +1,30 @@
+import datetime
+import sys
+
+from amplitude_python_sdk.v2.clients.releases_client import ReleasesAPIClient
+from amplitude_python_sdk.v2.models.releases import Release
+
+
+def main():
+ version = sys.argv[1]
+ version = version.removeprefix("v")
+
+ api_key = sys.argv[2]
+ secret_key = sys.argv[3]
+
+ current_datetime = datetime.datetime.now(datetime.timezone.utc)
+ current_datetime = current_datetime.strftime("%Y-%m-%d %H:%M:%S")
+
+ release = Release(
+ title=version,
+ version=version,
+ release_start=current_datetime,
+ created_by="GitHub Release Workflow",
+ chart_visibility=True
+ )
+
+ ReleasesAPIClient(api_key=api_key, secret_key=secret_key).create(release)
+
+
+if __name__ == "__main__":
+ main()
@@ -0,0 +1 @@
+amplitude-python-sdk==0.2.0
@@ -1,4 +1,5 @@
import Theme from "../themes/common/theme";
+import { withOpacity } from "../utils/color";
import {
backgroundColor,
border,
@@ -170,6 +171,24 @@ export default function editor(theme: Theme) {
background: backgroundColor(theme, "on500"),
},
},
+ scrollbar: {
+ width: 12,
+ minHeightFactor: 1.0,
+ track: {
+ border: {
+ left: true,
+ width: 1,
+ color: borderColor(theme, "secondary"),
+ },
+ },
+ thumb: {
+ background: withOpacity(borderColor(theme, "secondary"), 0.5),
+ border: {
+ width: 1,
+ color: withOpacity(borderColor(theme, 'muted'), 0.5),
+ }
+ }
+ },
compositionMark: {
underline: {
thickness: 1.0,
@@ -123,7 +123,7 @@ export function createTheme(
const borderColor = {
primary: sample(ramps.neutral, isLight ? 1.5 : 0),
secondary: sample(ramps.neutral, isLight ? 1.25 : 1),
- muted: sample(ramps.neutral, isLight ? 1 : 3),
+ muted: sample(ramps.neutral, isLight ? 1.25 : 3),
active: sample(ramps.neutral, isLight ? 4 : 3),
onMedia: withOpacity(darkest, 0.1),
ok: sample(ramps.green, 0.3),