diff --git a/Cargo.lock b/Cargo.lock index 01d398b51c..74cfa42bdf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2527,6 +2527,15 @@ dependencies = [ "hashbrown 0.9.1", ] +[[package]] +name = "indoc" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7906a9fababaeacb774f72410e497a1d18de916322e33797bb2cd29baa23c9e" +dependencies = [ + "unindent", +] + [[package]] name = "infer" version = "0.2.3" @@ -5553,9 +5562,9 @@ checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" [[package]] name = "unindent" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f14ee04d9415b52b3aeab06258a3f07093182b88ba0f9b8d203f211a7a7d41c7" +checksum = "514672a55d7380da379785a4d70ca8386c8883ff7eaae877be4d2081cebe73d8" [[package]] name = "universal-hash" @@ -5673,6 +5682,21 @@ version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5a972e5669d67ba988ce3dc826706fb0a8b01471c088cb0b6110b805cc36aed" +[[package]] +name = "vim" +version = "0.1.0" +dependencies = [ + "collections", + "editor", + "gpui", + "indoc", + "language", + "log", + "project", + "util", + "workspace", +] + [[package]] name = "waker-fn" version = "1.1.0" @@ -6003,6 +6027,7 @@ dependencies = [ "unindent", "url", "util", + "vim", "workspace", ] diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index 0c07f46ed9..ae2c4655a5 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -36,6 +36,7 @@ pub struct DisplayMap { wrap_map: ModelHandle, block_map: BlockMap, text_highlights: TextHighlights, + pub clip_at_line_ends: bool, } impl Entity for DisplayMap { @@ -67,6 +68,7 @@ impl DisplayMap { wrap_map, block_map, text_highlights: Default::default(), + clip_at_line_ends: false, } } @@ -87,6 +89,7 @@ impl DisplayMap { wraps_snapshot, blocks_snapshot, text_highlights: self.text_highlights.clone(), + clip_at_line_ends: self.clip_at_line_ends, } } @@ -205,6 +208,7 @@ pub struct DisplaySnapshot { wraps_snapshot: wrap_map::WrapSnapshot, blocks_snapshot: block_map::BlockSnapshot, text_highlights: TextHighlights, + clip_at_line_ends: bool, } impl DisplaySnapshot { @@ -332,7 +336,12 @@ impl DisplaySnapshot { } pub fn clip_point(&self, point: DisplayPoint, bias: Bias) -> DisplayPoint { - DisplayPoint(self.blocks_snapshot.clip_point(point.0, bias)) + let mut clipped = self.blocks_snapshot.clip_point(point.0, bias); + if self.clip_at_line_ends && clipped.column == self.line_len(clipped.row) { + clipped.column = clipped.column.saturating_sub(1); + clipped = self.blocks_snapshot.clip_point(clipped, Bias::Left); + } + DisplayPoint(clipped) } pub fn folds_in_range<'a, T>( @@ -488,19 +497,16 @@ impl ToDisplayPoint for Anchor { } #[cfg(test)] -mod tests { +pub mod tests { use super::*; - use crate::{ - movement, - test::{marked_text_ranges}, - }; + use crate::{movement, test::marked_display_snapshot}; use gpui::{color::Color, elements::*, test::observe, MutableAppContext}; use language::{Buffer, Language, LanguageConfig, RandomCharIter, SelectionGoal}; use rand::{prelude::*, Rng}; use smol::stream::StreamExt; use std::{env, sync::Arc}; use theme::SyntaxTheme; - use util::test::sample_text; + use util::test::{marked_text_ranges, sample_text}; use Bias::*; #[gpui::test(iterations = 100)] @@ -1133,49 +1139,70 @@ mod tests { #[gpui::test] fn test_clip_point(cx: &mut gpui::MutableAppContext) { + fn assert(text: &str, shift_right: bool, bias: Bias, cx: &mut gpui::MutableAppContext) { + let (unmarked_snapshot, mut markers) = marked_display_snapshot(text, cx); + + match bias { + Bias::Left => { + if shift_right { + *markers[1].column_mut() += 1; + } + + assert_eq!(unmarked_snapshot.clip_point(markers[1], bias), markers[0]) + } + Bias::Right => { + if shift_right { + *markers[0].column_mut() += 1; + } + + assert_eq!( + unmarked_snapshot.clip_point(dbg!(markers[0]), bias), + markers[1] + ) + } + }; + } + use Bias::{Left, Right}; + assert("||α", false, Left, cx); + assert("||α", true, Left, cx); + assert("||α", false, Right, cx); + assert("|α|", true, Right, cx); + assert("||✋", false, Left, cx); + assert("||✋", true, Left, cx); + assert("||✋", false, Right, cx); + assert("|✋|", true, Right, cx); + assert("||🍐", false, Left, cx); + assert("||🍐", true, Left, cx); + assert("||🍐", false, Right, cx); + assert("|🍐|", true, Right, cx); + assert("||\t", false, Left, cx); + assert("||\t", true, Left, cx); + assert("||\t", false, Right, cx); + assert("|\t|", true, Right, cx); + assert(" ||\t", false, Left, cx); + assert(" ||\t", true, Left, cx); + assert(" ||\t", false, Right, cx); + assert(" |\t|", true, Right, cx); + assert(" ||\t", false, Left, cx); + assert(" ||\t", false, Right, cx); + } - let text = "\n'a', 'α',\t'✋',\t'❎', '🍐'\n"; - let display_text = "\n'a', 'α', '✋', '❎', '🍐'\n"; - let buffer = MultiBuffer::build_simple(text, cx); - - let tab_size = 4; - let font_cache = cx.font_cache(); - let family_id = font_cache.load_family(&["Helvetica"]).unwrap(); - let font_id = font_cache - .select_font(family_id, &Default::default()) - .unwrap(); - let font_size = 14.0; - let map = cx.add_model(|cx| { - DisplayMap::new(buffer.clone(), tab_size, font_id, font_size, None, 1, 1, cx) - }); - let map = map.update(cx, |map, cx| map.snapshot(cx)); - - assert_eq!(map.text(), display_text); - for (input_column, bias, output_column) in vec![ - ("'a', '".len(), Left, "'a', '".len()), - ("'a', '".len() + 1, Left, "'a', '".len()), - ("'a', '".len() + 1, Right, "'a', 'α".len()), - ("'a', 'α', ".len(), Left, "'a', 'α',".len()), - ("'a', 'α', ".len(), Right, "'a', 'α', ".len()), - ("'a', 'α', '".len() + 1, Left, "'a', 'α', '".len()), - ("'a', 'α', '".len() + 1, Right, "'a', 'α', '✋".len()), - ("'a', 'α', '✋',".len(), Right, "'a', 'α', '✋',".len()), - ("'a', 'α', '✋', ".len(), Left, "'a', 'α', '✋',".len()), - ( - "'a', 'α', '✋', ".len(), - Right, - "'a', 'α', '✋', ".len(), - ), - ] { + #[gpui::test] + fn test_clip_at_line_ends(cx: &mut gpui::MutableAppContext) { + fn assert(text: &str, cx: &mut gpui::MutableAppContext) { + let (mut unmarked_snapshot, markers) = marked_display_snapshot(text, cx); + unmarked_snapshot.clip_at_line_ends = true; assert_eq!( - map.clip_point(DisplayPoint::new(1, input_column as u32), bias), - DisplayPoint::new(1, output_column as u32), - "clip_point(({}, {}))", - 1, - input_column, + unmarked_snapshot.clip_point(markers[1], Bias::Left), + markers[0] ); } + + assert("||", cx); + assert("|a|", cx); + assert("a|b|", cx); + assert("a|α|", cx); } #[gpui::test] diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index d841024f88..42ef59f69d 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -456,6 +456,8 @@ pub struct Editor { pending_rename: Option, searchable: bool, cursor_shape: CursorShape, + keymap_context_layers: BTreeMap, + input_enabled: bool, leader_replica_id: Option, } @@ -932,6 +934,8 @@ impl Editor { searchable: true, override_text_style: None, cursor_shape: Default::default(), + keymap_context_layers: Default::default(), + input_enabled: true, leader_replica_id: None, }; this.end_selection(cx); @@ -1000,6 +1004,10 @@ impl Editor { ) } + pub fn mode(&self) -> EditorMode { + self.mode + } + pub fn set_placeholder_text( &mut self, placeholder_text: impl Into>, @@ -1063,6 +1071,24 @@ impl Editor { cx.notify(); } + pub fn set_clip_at_line_ends(&mut self, clip: bool, cx: &mut ViewContext) { + self.display_map + .update(cx, |map, _| map.clip_at_line_ends = clip); + } + + pub fn set_keymap_context_layer(&mut self, context: gpui::keymap::Context) { + self.keymap_context_layers + .insert(TypeId::of::(), context); + } + + pub fn remove_keymap_context_layer(&mut self) { + self.keymap_context_layers.remove(&TypeId::of::()); + } + + pub fn set_input_enabled(&mut self, input_enabled: bool) { + self.input_enabled = input_enabled; + } + pub fn scroll_position(&self, cx: &mut ViewContext) -> Vector2F { let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); compute_scroll_position(&display_map, self.scroll_position, &self.scroll_top_anchor) @@ -1742,6 +1768,11 @@ impl Editor { } pub fn handle_input(&mut self, action: &Input, cx: &mut ViewContext) { + if !self.input_enabled { + cx.propagate_action(); + return; + } + let text = action.0.as_ref(); if !self.skip_autoclose_end(text, cx) { self.transact(cx, |this, cx| { @@ -5741,26 +5772,31 @@ impl View for Editor { } fn keymap_context(&self, _: &AppContext) -> gpui::keymap::Context { - let mut cx = Self::default_keymap_context(); + let mut context = Self::default_keymap_context(); let mode = match self.mode { EditorMode::SingleLine => "single_line", EditorMode::AutoHeight { .. } => "auto_height", EditorMode::Full => "full", }; - cx.map.insert("mode".into(), mode.into()); + context.map.insert("mode".into(), mode.into()); if self.pending_rename.is_some() { - cx.set.insert("renaming".into()); + context.set.insert("renaming".into()); } match self.context_menu.as_ref() { Some(ContextMenu::Completions(_)) => { - cx.set.insert("showing_completions".into()); + context.set.insert("showing_completions".into()); } Some(ContextMenu::CodeActions(_)) => { - cx.set.insert("showing_code_actions".into()); + context.set.insert("showing_code_actions".into()); } None => {} } - cx + + for layer in self.keymap_context_layers.values() { + context.extend(layer); + } + + context } } @@ -6139,7 +6175,6 @@ pub fn styled_runs_for_code_label<'a>( #[cfg(test)] mod tests { - use crate::test::marked_text_by; use super::*; use gpui::{ @@ -6153,7 +6188,7 @@ mod tests { use std::{cell::RefCell, rc::Rc, time::Instant}; use text::Point; use unindent::Unindent; - use util::test::sample_text; + use util::test::{marked_text_by, sample_text}; use workspace::FollowableItem; #[gpui::test] diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 0daf8f2fc2..18f780dacc 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -1292,7 +1292,7 @@ impl PaintState { } } -#[derive(Copy, Clone)] +#[derive(Copy, Clone, PartialEq, Eq)] pub enum CursorShape { Bar, Block, diff --git a/crates/editor/src/movement.rs b/crates/editor/src/movement.rs index 13f66bb24b..cf2d772b16 100644 --- a/crates/editor/src/movement.rs +++ b/crates/editor/src/movement.rs @@ -266,13 +266,13 @@ pub fn surrounding_word(map: &DisplaySnapshot, position: DisplayPoint) -> Range< #[cfg(test)] mod tests { use super::*; - use crate::{test::marked_text, Buffer, DisplayMap, MultiBuffer}; + use crate::{test::marked_display_snapshot, Buffer, DisplayMap, MultiBuffer}; use language::Point; #[gpui::test] fn test_previous_word_start(cx: &mut gpui::MutableAppContext) { fn assert(marked_text: &str, cx: &mut gpui::MutableAppContext) { - let (snapshot, display_points) = marked_snapshot(marked_text, cx); + let (snapshot, display_points) = marked_display_snapshot(marked_text, cx); assert_eq!( previous_word_start(&snapshot, display_points[1]), display_points[0] @@ -298,7 +298,7 @@ mod tests { #[gpui::test] fn test_previous_subword_start(cx: &mut gpui::MutableAppContext) { fn assert(marked_text: &str, cx: &mut gpui::MutableAppContext) { - let (snapshot, display_points) = marked_snapshot(marked_text, cx); + let (snapshot, display_points) = marked_display_snapshot(marked_text, cx); assert_eq!( previous_subword_start(&snapshot, display_points[1]), display_points[0] @@ -335,7 +335,7 @@ mod tests { cx: &mut gpui::MutableAppContext, is_boundary: impl FnMut(char, char) -> bool, ) { - let (snapshot, display_points) = marked_snapshot(marked_text, cx); + let (snapshot, display_points) = marked_display_snapshot(marked_text, cx); assert_eq!( find_preceding_boundary(&snapshot, display_points[1], is_boundary), display_points[0] @@ -362,7 +362,7 @@ mod tests { #[gpui::test] fn test_next_word_end(cx: &mut gpui::MutableAppContext) { fn assert(marked_text: &str, cx: &mut gpui::MutableAppContext) { - let (snapshot, display_points) = marked_snapshot(marked_text, cx); + let (snapshot, display_points) = marked_display_snapshot(marked_text, cx); assert_eq!( next_word_end(&snapshot, display_points[0]), display_points[1] @@ -385,7 +385,7 @@ mod tests { #[gpui::test] fn test_next_subword_end(cx: &mut gpui::MutableAppContext) { fn assert(marked_text: &str, cx: &mut gpui::MutableAppContext) { - let (snapshot, display_points) = marked_snapshot(marked_text, cx); + let (snapshot, display_points) = marked_display_snapshot(marked_text, cx); assert_eq!( next_subword_end(&snapshot, display_points[0]), display_points[1] @@ -421,7 +421,7 @@ mod tests { cx: &mut gpui::MutableAppContext, is_boundary: impl FnMut(char, char) -> bool, ) { - let (snapshot, display_points) = marked_snapshot(marked_text, cx); + let (snapshot, display_points) = marked_display_snapshot(marked_text, cx); assert_eq!( find_boundary(&snapshot, display_points[0], is_boundary), display_points[1] @@ -448,7 +448,7 @@ mod tests { #[gpui::test] fn test_surrounding_word(cx: &mut gpui::MutableAppContext) { fn assert(marked_text: &str, cx: &mut gpui::MutableAppContext) { - let (snapshot, display_points) = marked_snapshot(marked_text, cx); + let (snapshot, display_points) = marked_display_snapshot(marked_text, cx); assert_eq!( surrounding_word(&snapshot, display_points[1]), display_points[0]..display_points[2] @@ -532,31 +532,4 @@ mod tests { (DisplayPoint::new(7, 2), SelectionGoal::Column(2)), ); } - - // Returns a snapshot from text containing '|' character markers with the markers removed, and DisplayPoints for each one. - fn marked_snapshot( - text: &str, - cx: &mut gpui::MutableAppContext, - ) -> (DisplaySnapshot, Vec) { - let (unmarked_text, markers) = marked_text(text); - - let tab_size = 4; - let family_id = cx.font_cache().load_family(&["Helvetica"]).unwrap(); - let font_id = cx - .font_cache() - .select_font(family_id, &Default::default()) - .unwrap(); - let font_size = 14.0; - - let buffer = MultiBuffer::build_simple(&unmarked_text, cx); - let display_map = cx - .add_model(|cx| DisplayMap::new(buffer, tab_size, font_id, font_size, None, 1, 1, cx)); - let snapshot = display_map.update(cx, |map, cx| map.snapshot(cx)); - let markers = markers - .into_iter() - .map(|offset| offset.to_display_point(&snapshot)) - .collect(); - - (snapshot, markers) - } } diff --git a/crates/editor/src/test.rs b/crates/editor/src/test.rs index 566ca9b2db..e80547c9dd 100644 --- a/crates/editor/src/test.rs +++ b/crates/editor/src/test.rs @@ -1,6 +1,9 @@ -use std::ops::Range; +use util::test::marked_text; -use collections::HashMap; +use crate::{ + display_map::{DisplayMap, DisplaySnapshot, ToDisplayPoint}, + DisplayPoint, MultiBuffer, +}; #[cfg(test)] #[ctor::ctor] @@ -10,47 +13,29 @@ fn init_logger() { } } -pub fn marked_text_by( - marked_text: &str, - markers: Vec, -) -> (String, HashMap>) { - let mut extracted_markers: HashMap> = Default::default(); - let mut unmarked_text = String::new(); +// Returns a snapshot from text containing '|' character markers with the markers removed, and DisplayPoints for each one. +pub fn marked_display_snapshot( + text: &str, + cx: &mut gpui::MutableAppContext, +) -> (DisplaySnapshot, Vec) { + let (unmarked_text, markers) = marked_text(text); - for char in marked_text.chars() { - if markers.contains(&char) { - let char_offsets = extracted_markers.entry(char).or_insert(Vec::new()); - char_offsets.push(unmarked_text.len()); - } else { - unmarked_text.push(char); - } - } + let tab_size = 4; + let family_id = cx.font_cache().load_family(&["Helvetica"]).unwrap(); + let font_id = cx + .font_cache() + .select_font(family_id, &Default::default()) + .unwrap(); + let font_size = 14.0; - (unmarked_text, extracted_markers) -} - -pub fn marked_text(marked_text: &str) -> (String, Vec) { - let (unmarked_text, mut markers) = marked_text_by(marked_text, vec!['|']); - (unmarked_text, markers.remove(&'|').unwrap_or_else(Vec::new)) -} - -pub fn marked_text_ranges( - marked_text: &str, - range_markers: Vec<(char, char)>, -) -> (String, Vec>) { - let mut marker_chars = Vec::new(); - for (start, end) in range_markers.iter() { - marker_chars.push(*start); - marker_chars.push(*end); - } - let (unmarked_text, markers) = marked_text_by(marked_text, marker_chars); - let ranges = range_markers - .iter() - .map(|(start_marker, end_marker)| { - let start = markers.get(start_marker).unwrap()[0]; - let end = markers.get(end_marker).unwrap()[0]; - start..end - }) + let buffer = MultiBuffer::build_simple(&unmarked_text, cx); + let display_map = + cx.add_model(|cx| DisplayMap::new(buffer, tab_size, font_id, font_size, None, 1, 1, cx)); + let snapshot = display_map.update(cx, |map, cx| map.snapshot(cx)); + let markers = markers + .into_iter() + .map(|offset| offset.to_display_point(&snapshot)) .collect(); - (unmarked_text, ranges) + + (snapshot, markers) } diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index db21612439..a291ad3c4b 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -442,13 +442,32 @@ impl TestAppContext { } pub fn dispatch_keystroke( - &self, + &mut self, window_id: usize, - responder_chain: Vec, - keystroke: &Keystroke, - ) -> Result { - let mut state = self.cx.borrow_mut(); - state.dispatch_keystroke(window_id, responder_chain, keystroke) + keystroke: Keystroke, + input: Option, + is_held: bool, + ) { + self.cx.borrow_mut().update(|cx| { + let presenter = cx + .presenters_and_platform_windows + .get(&window_id) + .unwrap() + .0 + .clone(); + let responder_chain = presenter.borrow().dispatch_path(cx.as_ref()); + + if !cx.dispatch_keystroke(window_id, responder_chain, &keystroke) { + presenter.borrow_mut().dispatch_event( + Event::KeyDown { + keystroke, + input, + is_held, + }, + cx, + ); + } + }); } pub fn add_model(&mut self, build_model: F) -> ModelHandle @@ -503,7 +522,7 @@ impl TestAppContext { pub fn update T>(&mut self, callback: F) -> T { let mut state = self.cx.borrow_mut(); - // Don't increment pending flushes in order to effects to be flushed before the callback + // 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 @@ -1250,9 +1269,9 @@ impl MutableAppContext { } } - fn defer(&mut self, callback: Box) { + pub fn defer(&mut self, callback: impl 'static + FnOnce(&mut MutableAppContext)) { self.pending_effects.push_back(Effect::Deferred { - callback, + callback: Box::new(callback), after_window_update: false, }) } @@ -1379,17 +1398,15 @@ impl MutableAppContext { window_id: usize, responder_chain: Vec, keystroke: &Keystroke, - ) -> Result { + ) -> bool { let mut context_chain = Vec::new(); for view_id in &responder_chain { - if let Some(view) = self.cx.views.get(&(window_id, *view_id)) { - context_chain.push(view.keymap_context(self.as_ref())); - } else { - return Err(anyhow!( - "View {} in responder chain does not exist", - view_id - )); - } + let view = self + .cx + .views + .get(&(window_id, *view_id)) + .expect("view in responder chain does not exist"); + context_chain.push(view.keymap_context(self.as_ref())); } let mut pending = false; @@ -1404,13 +1421,13 @@ impl MutableAppContext { if self.dispatch_action_any(window_id, &responder_chain[0..=i], action.as_ref()) { self.keystroke_matcher.clear_pending(); - return Ok(true); + return true; } } } } - Ok(pending) + pending } pub fn default_global(&mut self) -> &T { @@ -1540,14 +1557,11 @@ impl MutableAppContext { window.on_event(Box::new(move |event| { app.update(|cx| { if let Event::KeyDown { keystroke, .. } = &event { - if cx - .dispatch_keystroke( - window_id, - presenter.borrow().dispatch_path(cx.as_ref()), - keystroke, - ) - .unwrap() - { + if cx.dispatch_keystroke( + window_id, + presenter.borrow().dispatch_path(cx.as_ref()), + keystroke, + ) { return; } } @@ -2711,11 +2725,11 @@ impl<'a, T: Entity> ModelContext<'a, T> { pub fn defer(&mut self, callback: impl 'static + FnOnce(&mut T, &mut ModelContext)) { let handle = self.handle(); - self.app.defer(Box::new(move |cx| { + self.app.defer(move |cx| { handle.update(cx, |model, cx| { callback(model, cx); }) - })) + }) } pub fn emit(&mut self, payload: T::Event) { @@ -3064,11 +3078,11 @@ impl<'a, T: View> ViewContext<'a, T> { pub fn defer(&mut self, callback: impl 'static + FnOnce(&mut T, &mut ViewContext)) { let handle = self.handle(); - self.app.defer(Box::new(move |cx| { + self.app.defer(move |cx| { handle.update(cx, |view, cx| { callback(view, cx); }) - })) + }) } pub fn after_window_update( @@ -3678,9 +3692,9 @@ impl ViewHandle { F: 'static + FnOnce(&mut T, &mut ViewContext), { let this = self.clone(); - cx.as_mut().defer(Box::new(move |cx| { + cx.as_mut().defer(move |cx| { this.update(cx, |view, cx| update(view, cx)); - })); + }); } pub fn is_focused(&self, cx: &AppContext) -> bool { @@ -5921,8 +5935,7 @@ mod tests { window_id, vec![view_1.id(), view_2.id(), view_3.id()], &Keystroke::parse("a").unwrap(), - ) - .unwrap(); + ); assert_eq!(&*actions.borrow(), &["2 a"]); @@ -5931,8 +5944,7 @@ mod tests { window_id, vec![view_1.id(), view_2.id(), view_3.id()], &Keystroke::parse("b").unwrap(), - ) - .unwrap(); + ); assert_eq!(&*actions.borrow(), &["3 b", "2 b", "1 b", "global b"]); } diff --git a/crates/gpui/src/keymap.rs b/crates/gpui/src/keymap.rs index 05fbd5b74b..37223d77d1 100644 --- a/crates/gpui/src/keymap.rs +++ b/crates/gpui/src/keymap.rs @@ -224,15 +224,19 @@ impl Keystroke { key: key.unwrap(), }) } + + pub fn modified(&self) -> bool { + self.ctrl || self.alt || self.shift || self.cmd + } } impl Context { - pub fn extend(&mut self, other: Context) { - for v in other.set { - self.set.insert(v); + pub fn extend(&mut self, other: &Context) { + for v in &other.set { + self.set.insert(v.clone()); } - for (k, v) in other.map { - self.map.insert(k, v); + for (k, v) in &other.map { + self.map.insert(k.clone(), v.clone()); } } } diff --git a/crates/text/src/selection.rs b/crates/text/src/selection.rs index 817fde7d66..23b0d2b3b0 100644 --- a/crates/text/src/selection.rs +++ b/crates/text/src/selection.rs @@ -40,6 +40,19 @@ impl Selection { self.start.clone() } } + + pub fn map(&self, f: F) -> Selection + where + F: Fn(T) -> S, + { + Selection:: { + id: self.id, + start: f(self.start.clone()), + end: f(self.end.clone()), + reversed: self.reversed, + goal: self.goal, + } + } } impl Selection { diff --git a/crates/util/src/test.rs b/crates/util/src/test.rs index 71b847df69..b4cf25274e 100644 --- a/crates/util/src/test.rs +++ b/crates/util/src/test.rs @@ -1,4 +1,8 @@ -use std::path::{Path, PathBuf}; +use std::{ + collections::HashMap, + ops::Range, + path::{Path, PathBuf}, +}; use tempdir::TempDir; pub fn temp_tree(tree: serde_json::Value) -> TempDir { @@ -48,3 +52,48 @@ pub fn sample_text(rows: usize, cols: usize, start_char: char) -> String { } text } + +pub fn marked_text_by( + marked_text: &str, + markers: Vec, +) -> (String, HashMap>) { + let mut extracted_markers: HashMap> = Default::default(); + let mut unmarked_text = String::new(); + + for char in marked_text.chars() { + if markers.contains(&char) { + let char_offsets = extracted_markers.entry(char).or_insert(Vec::new()); + char_offsets.push(unmarked_text.len()); + } else { + unmarked_text.push(char); + } + } + + (unmarked_text, extracted_markers) +} + +pub fn marked_text(marked_text: &str) -> (String, Vec) { + let (unmarked_text, mut markers) = marked_text_by(marked_text, vec!['|']); + (unmarked_text, markers.remove(&'|').unwrap_or_else(Vec::new)) +} + +pub fn marked_text_ranges( + marked_text: &str, + range_markers: Vec<(char, char)>, +) -> (String, Vec>) { + let mut marker_chars = Vec::new(); + for (start, end) in range_markers.iter() { + marker_chars.push(*start); + marker_chars.push(*end); + } + let (unmarked_text, markers) = marked_text_by(marked_text, marker_chars); + let ranges = range_markers + .iter() + .map(|(start_marker, end_marker)| { + let start = markers.get(start_marker).unwrap()[0]; + let end = markers.get(end_marker).unwrap()[0]; + start..end + }) + .collect(); + (unmarked_text, ranges) +} diff --git a/crates/vim/Cargo.toml b/crates/vim/Cargo.toml new file mode 100644 index 0000000000..28ee7de872 --- /dev/null +++ b/crates/vim/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "vim" +version = "0.1.0" +edition = "2021" + +[lib] +path = "src/vim.rs" +doctest = false + +[dependencies] +collections = { path = "../collections" } +editor = { path = "../editor" } +gpui = { path = "../gpui" } +language = { path = "../language" } +workspace = { path = "../workspace" } +log = "0.4" + +[dev-dependencies] +indoc = "1.0.4" +editor = { path = "../editor", features = ["test-support"] } +gpui = { path = "../gpui", features = ["test-support"] } +project = { path = "../project", features = ["test-support"] } +language = { path = "../language", features = ["test-support"] } +util = { path = "../util", features = ["test-support"] } +workspace = { path = "../workspace", features = ["test-support"] } \ No newline at end of file diff --git a/crates/vim/src/editor_events.rs b/crates/vim/src/editor_events.rs new file mode 100644 index 0000000000..2f8a60ef33 --- /dev/null +++ b/crates/vim/src/editor_events.rs @@ -0,0 +1,53 @@ +use editor::{EditorBlurred, EditorCreated, EditorFocused, EditorMode, EditorReleased}; +use gpui::MutableAppContext; + +use crate::{mode::Mode, SwitchMode, VimState}; + +pub fn init(cx: &mut MutableAppContext) { + cx.subscribe_global(editor_created).detach(); + cx.subscribe_global(editor_focused).detach(); + cx.subscribe_global(editor_blurred).detach(); + cx.subscribe_global(editor_released).detach(); +} + +fn editor_created(EditorCreated(editor): &EditorCreated, cx: &mut MutableAppContext) { + cx.update_default_global(|vim_state: &mut VimState, cx| { + vim_state.editors.insert(editor.id(), editor.downgrade()); + VimState::sync_editor_options(cx); + }) +} + +fn editor_focused(EditorFocused(editor): &EditorFocused, cx: &mut MutableAppContext) { + let mode = if matches!(editor.read(cx).mode(), EditorMode::SingleLine) { + Mode::Insert + } else { + Mode::Normal + }; + + cx.update_default_global(|vim_state: &mut VimState, _| { + vim_state.active_editor = Some(editor.downgrade()); + }); + VimState::switch_mode(&SwitchMode(mode), cx); +} + +fn editor_blurred(EditorBlurred(editor): &EditorBlurred, cx: &mut MutableAppContext) { + cx.update_default_global(|vim_state: &mut VimState, _| { + if let Some(previous_editor) = vim_state.active_editor.clone() { + if previous_editor == editor.clone() { + vim_state.active_editor = None; + } + } + }); + VimState::sync_editor_options(cx); +} + +fn editor_released(EditorReleased(editor): &EditorReleased, cx: &mut MutableAppContext) { + cx.update_default_global(|vim_state: &mut VimState, _| { + vim_state.editors.remove(&editor.id()); + if let Some(previous_editor) = vim_state.active_editor.clone() { + if previous_editor == editor.clone() { + vim_state.active_editor = None; + } + } + }); +} diff --git a/crates/vim/src/insert.rs b/crates/vim/src/insert.rs new file mode 100644 index 0000000000..50fd53b37b --- /dev/null +++ b/crates/vim/src/insert.rs @@ -0,0 +1,28 @@ +use editor::Bias; +use gpui::{action, keymap::Binding, MutableAppContext, ViewContext}; +use language::SelectionGoal; +use workspace::Workspace; + +use crate::{mode::Mode, SwitchMode, VimState}; + +action!(NormalBefore); + +pub fn init(cx: &mut MutableAppContext) { + let context = Some("Editor && vim_mode == insert"); + cx.add_bindings(vec![ + Binding::new("escape", NormalBefore, context), + Binding::new("ctrl-c", NormalBefore, context), + ]); + + cx.add_action(normal_before); +} + +fn normal_before(_: &mut Workspace, _: &NormalBefore, cx: &mut ViewContext) { + VimState::update_active_editor(cx, |editor, cx| { + editor.move_cursors(cx, |map, mut cursor, _| { + *cursor.column_mut() = cursor.column().saturating_sub(1); + (map.clip_point(cursor, Bias::Left), SelectionGoal::None) + }); + }); + VimState::switch_mode(&SwitchMode(Mode::Normal), cx); +} diff --git a/crates/vim/src/mode.rs b/crates/vim/src/mode.rs new file mode 100644 index 0000000000..2438a9fa3d --- /dev/null +++ b/crates/vim/src/mode.rs @@ -0,0 +1,36 @@ +use editor::CursorShape; +use gpui::keymap::Context; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Mode { + Normal, + Insert, +} + +impl Mode { + pub fn cursor_shape(&self) -> CursorShape { + match self { + Mode::Normal => CursorShape::Block, + Mode::Insert => CursorShape::Bar, + } + } + + pub fn keymap_context_layer(&self) -> Context { + let mut context = Context::default(); + context.map.insert( + "vim_mode".to_string(), + match self { + Self::Normal => "normal", + Self::Insert => "insert", + } + .to_string(), + ); + context + } +} + +impl Default for Mode { + fn default() -> Self { + Self::Normal + } +} diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs new file mode 100644 index 0000000000..87218b3f5f --- /dev/null +++ b/crates/vim/src/normal.rs @@ -0,0 +1,58 @@ +use editor::{movement, Bias}; +use gpui::{action, keymap::Binding, MutableAppContext, ViewContext}; +use language::SelectionGoal; +use workspace::Workspace; + +use crate::{Mode, SwitchMode, VimState}; + +action!(InsertBefore); +action!(MoveLeft); +action!(MoveDown); +action!(MoveUp); +action!(MoveRight); + +pub fn init(cx: &mut MutableAppContext) { + let context = Some("Editor && vim_mode == normal"); + cx.add_bindings(vec![ + Binding::new("i", SwitchMode(Mode::Insert), context), + Binding::new("h", MoveLeft, context), + Binding::new("j", MoveDown, context), + Binding::new("k", MoveUp, context), + Binding::new("l", MoveRight, context), + ]); + + cx.add_action(move_left); + cx.add_action(move_down); + cx.add_action(move_up); + cx.add_action(move_right); +} + +fn move_left(_: &mut Workspace, _: &MoveLeft, cx: &mut ViewContext) { + VimState::update_active_editor(cx, |editor, cx| { + editor.move_cursors(cx, |map, mut cursor, _| { + *cursor.column_mut() = cursor.column().saturating_sub(1); + (map.clip_point(cursor, Bias::Left), SelectionGoal::None) + }); + }); +} + +fn move_down(_: &mut Workspace, _: &MoveDown, cx: &mut ViewContext) { + VimState::update_active_editor(cx, |editor, cx| { + editor.move_cursors(cx, movement::down); + }); +} + +fn move_up(_: &mut Workspace, _: &MoveUp, cx: &mut ViewContext) { + VimState::update_active_editor(cx, |editor, cx| { + editor.move_cursors(cx, movement::up); + }); +} + +fn move_right(_: &mut Workspace, _: &MoveRight, cx: &mut ViewContext) { + VimState::update_active_editor(cx, |editor, cx| { + editor.move_cursors(cx, |map, mut cursor, _| { + *cursor.column_mut() += 1; + (map.clip_point(cursor, Bias::Right), SelectionGoal::None) + }); + }); +} diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs new file mode 100644 index 0000000000..68ecd4e003 --- /dev/null +++ b/crates/vim/src/vim.rs @@ -0,0 +1,96 @@ +mod editor_events; +mod insert; +mod mode; +mod normal; +#[cfg(test)] +mod vim_tests; + +use collections::HashMap; +use editor::{CursorShape, Editor}; +use gpui::{action, MutableAppContext, ViewContext, WeakViewHandle}; + +use mode::Mode; +use workspace::{self, Settings, Workspace}; + +action!(SwitchMode, Mode); + +pub fn init(cx: &mut MutableAppContext) { + editor_events::init(cx); + insert::init(cx); + normal::init(cx); + + cx.add_action(|_: &mut Workspace, action: &SwitchMode, cx| VimState::switch_mode(action, cx)); + + cx.observe_global::(VimState::settings_changed) + .detach(); +} + +#[derive(Default)] +pub struct VimState { + editors: HashMap>, + active_editor: Option>, + + enabled: bool, + mode: Mode, +} + +impl VimState { + fn update_active_editor( + cx: &mut MutableAppContext, + update: impl FnOnce(&mut Editor, &mut ViewContext) -> S, + ) -> Option { + cx.global::() + .active_editor + .clone() + .and_then(|ae| ae.upgrade(cx)) + .map(|ae| ae.update(cx, update)) + } + + fn switch_mode(SwitchMode(mode): &SwitchMode, cx: &mut MutableAppContext) { + cx.update_default_global(|this: &mut Self, _| { + this.mode = *mode; + }); + + VimState::sync_editor_options(cx); + } + + fn settings_changed(cx: &mut MutableAppContext) { + cx.update_default_global(|this: &mut Self, cx| { + let settings = cx.global::(); + if this.enabled != settings.vim_mode { + this.enabled = settings.vim_mode; + this.mode = if settings.vim_mode { + Mode::Normal + } else { + Mode::Insert + }; + Self::sync_editor_options(cx); + } + }); + } + + fn sync_editor_options(cx: &mut MutableAppContext) { + cx.defer(move |cx| { + cx.update_default_global(|this: &mut VimState, cx| { + let mode = this.mode; + let cursor_shape = mode.cursor_shape(); + let keymap_layer_active = this.enabled; + for editor in this.editors.values() { + if let Some(editor) = editor.upgrade(cx) { + editor.update(cx, |editor, cx| { + editor.set_cursor_shape(cursor_shape, cx); + editor.set_clip_at_line_ends(cursor_shape == CursorShape::Block, cx); + editor.set_input_enabled(mode == Mode::Insert); + if keymap_layer_active { + let context_layer = mode.keymap_context_layer(); + editor.set_keymap_context_layer::(context_layer); + } else { + editor.remove_keymap_context_layer::(); + } + }); + } + } + }); + }); + } +} diff --git a/crates/vim/src/vim_tests.rs b/crates/vim/src/vim_tests.rs new file mode 100644 index 0000000000..e35ed9a122 --- /dev/null +++ b/crates/vim/src/vim_tests.rs @@ -0,0 +1,243 @@ +use indoc::indoc; +use std::ops::Deref; + +use editor::{display_map::ToDisplayPoint, DisplayPoint}; +use gpui::{json::json, keymap::Keystroke, ViewHandle}; +use language::{Point, Selection}; +use util::test::marked_text; +use workspace::{WorkspaceHandle, WorkspaceParams}; + +use crate::*; + +#[gpui::test] +async fn test_insert_mode(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestAppContext::new(cx, "").await; + cx.simulate_keystroke("i"); + assert_eq!(cx.mode(), Mode::Insert); + cx.simulate_keystrokes(&["T", "e", "s", "t"]); + cx.assert_newest_selection_head("Test|"); + cx.simulate_keystroke("escape"); + assert_eq!(cx.mode(), Mode::Normal); + cx.assert_newest_selection_head("Tes|t"); +} + +#[gpui::test] +async fn test_normal_hjkl(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestAppContext::new(cx, "Test\nTestTest\nTest").await; + cx.simulate_keystroke("l"); + cx.assert_newest_selection_head(indoc! {" + T|est + TestTest + Test"}); + cx.simulate_keystroke("h"); + cx.assert_newest_selection_head(indoc! {" + |Test + TestTest + Test"}); + cx.simulate_keystroke("j"); + cx.assert_newest_selection_head(indoc! {" + Test + |TestTest + Test"}); + cx.simulate_keystroke("k"); + cx.assert_newest_selection_head(indoc! {" + |Test + TestTest + Test"}); + cx.simulate_keystroke("j"); + cx.assert_newest_selection_head(indoc! {" + Test + |TestTest + Test"}); + + // When moving left, cursor does not wrap to the previous line + cx.simulate_keystroke("h"); + cx.assert_newest_selection_head(indoc! {" + Test + |TestTest + Test"}); + + // When moving right, cursor does not reach the line end or wrap to the next line + for _ in 0..9 { + cx.simulate_keystroke("l"); + } + cx.assert_newest_selection_head(indoc! {" + Test + TestTes|t + Test"}); + + // Goal column respects the inability to reach the end of the line + cx.simulate_keystroke("k"); + cx.assert_newest_selection_head(indoc! {" + Tes|t + TestTest + Test"}); + cx.simulate_keystroke("j"); + cx.assert_newest_selection_head(indoc! {" + Test + TestTes|t + Test"}); +} + +#[gpui::test] +async fn test_toggle_through_settings(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestAppContext::new(cx, "").await; + + // Editor acts as though vim is disabled + cx.disable_vim(); + assert_eq!(cx.mode(), Mode::Insert); + cx.simulate_keystrokes(&["h", "j", "k", "l"]); + cx.assert_newest_selection_head("hjkl|"); + + // Enabling dynamically sets vim mode again + cx.enable_vim(); + assert_eq!(cx.mode(), Mode::Normal); + cx.simulate_keystrokes(&["h", "h", "h", "l"]); + assert_eq!(cx.editor_text(), "hjkl".to_owned()); + cx.assert_newest_selection_head("hj|kl"); + cx.simulate_keystrokes(&["i", "T", "e", "s", "t"]); + cx.assert_newest_selection_head("hjTest|kl"); + + // Disabling and enabling resets to normal mode + assert_eq!(cx.mode(), Mode::Insert); + cx.disable_vim(); + assert_eq!(cx.mode(), Mode::Insert); + cx.enable_vim(); + assert_eq!(cx.mode(), Mode::Normal); +} + +struct VimTestAppContext<'a> { + cx: &'a mut gpui::TestAppContext, + window_id: usize, + editor: ViewHandle, +} + +impl<'a> VimTestAppContext<'a> { + async fn new( + cx: &'a mut gpui::TestAppContext, + initial_editor_text: &str, + ) -> VimTestAppContext<'a> { + cx.update(|cx| { + editor::init(cx); + crate::init(cx); + }); + let params = cx.update(WorkspaceParams::test); + + cx.update(|cx| { + cx.update_global(|settings: &mut Settings, _| { + settings.vim_mode = true; + }); + }); + + params + .fs + .as_fake() + .insert_tree( + "/root", + json!({ "dir": { "test.txt": initial_editor_text } }), + ) + .await; + + let (window_id, workspace) = cx.add_window(|cx| Workspace::new(¶ms, cx)); + params + .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, cx)) + .await + .expect("Could not open test file"); + + let editor = cx.update(|cx| { + item.act_as::(cx) + .expect("Opened test file wasn't an editor") + }); + editor.update(cx, |_, cx| cx.focus_self()); + + Self { + cx, + window_id, + editor, + } + } + + fn enable_vim(&mut self) { + self.cx.update(|cx| { + cx.update_global(|settings: &mut Settings, _| { + settings.vim_mode = true; + }); + }) + } + + fn disable_vim(&mut self) { + self.cx.update(|cx| { + cx.update_global(|settings: &mut Settings, _| { + settings.vim_mode = false; + }); + }) + } + + fn newest_selection(&mut self) -> Selection { + self.editor.update(self.cx, |editor, cx| { + let snapshot = editor.snapshot(cx); + editor + .newest_selection::(cx) + .map(|point| point.to_display_point(&snapshot.display_snapshot)) + }) + } + + fn mode(&mut self) -> Mode { + self.cx.update(|cx| cx.global::().mode) + } + + fn editor_text(&mut self) -> String { + self.editor + .update(self.cx, |editor, cx| editor.snapshot(cx).text()) + } + + fn simulate_keystroke(&mut self, keystroke_text: &str) { + let keystroke = Keystroke::parse(keystroke_text).unwrap(); + let input = if keystroke.modified() { + None + } else { + Some(keystroke.key.clone()) + }; + self.cx + .dispatch_keystroke(self.window_id, keystroke, input, false); + } + + fn simulate_keystrokes(&mut self, keystroke_texts: &[&str]) { + for keystroke_text in keystroke_texts.into_iter() { + self.simulate_keystroke(keystroke_text); + } + } + + fn assert_newest_selection_head(&mut self, text: &str) { + let (unmarked_text, markers) = marked_text(&text); + assert_eq!( + self.editor_text(), + unmarked_text, + "Unmarked text doesn't match editor text" + ); + let newest_selection = self.newest_selection(); + let expected_head = self.editor.update(self.cx, |editor, cx| { + markers[0].to_display_point(&editor.snapshot(cx)) + }); + assert_eq!(newest_selection.head(), expected_head) + } +} + +impl<'a> Deref for VimTestAppContext<'a> { + type Target = gpui::TestAppContext; + + fn deref(&self) -> &Self::Target { + self.cx + } +} diff --git a/crates/workspace/src/settings.rs b/crates/workspace/src/settings.rs index e97f7f8920..5ccf8056e6 100644 --- a/crates/workspace/src/settings.rs +++ b/crates/workspace/src/settings.rs @@ -17,6 +17,7 @@ use util::ResultExt; pub struct Settings { pub buffer_font_family: FamilyId, pub buffer_font_size: f32, + pub vim_mode: bool, pub tab_size: usize, pub soft_wrap: SoftWrap, pub preferred_line_length: u32, @@ -48,6 +49,8 @@ struct SettingsFileContent { buffer_font_family: Option, #[serde(default)] buffer_font_size: Option, + #[serde(default)] + vim_mode: Option, #[serde(flatten)] editor: LanguageOverride, #[serde(default)] @@ -130,6 +133,7 @@ impl Settings { Ok(Self { buffer_font_family: font_cache.load_family(&[buffer_font_family])?, buffer_font_size: 15., + vim_mode: false, tab_size: 4, soft_wrap: SoftWrap::None, preferred_line_length: 80, @@ -174,6 +178,7 @@ impl Settings { Settings { buffer_font_family: cx.font_cache().load_family(&["Monaco"]).unwrap(), buffer_font_size: 14., + vim_mode: false, tab_size: 4, soft_wrap: SoftWrap::None, preferred_line_length: 80, @@ -200,6 +205,7 @@ impl Settings { } merge(&mut self.buffer_font_size, data.buffer_font_size); + merge(&mut self.vim_mode, data.vim_mode); merge(&mut self.soft_wrap, data.editor.soft_wrap); merge(&mut self.tab_size, data.editor.tab_size); merge( diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index ca0eec353e..e541dff42d 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -55,6 +55,7 @@ text = { path = "../text" } theme = { path = "../theme" } theme_selector = { path = "../theme_selector" } util = { path = "../util" } +vim = { path = "../vim" } workspace = { path = "../workspace" } anyhow = "1.0.38" async-compression = { version = "0.3", features = ["gzip", "futures-bufread"] } diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index b79f7f1bb0..63721346c3 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -78,6 +78,7 @@ fn main() { project_panel::init(cx); diagnostics::init(cx); search::init(cx); + vim::init(cx); cx.spawn({ let client = client.clone(); |cx| async move {