Merge branch 'main' of github.com:zed-industries/zed into project_search_design

KCaverly created

Change summary

Cargo.lock                                                             |   1 
assets/keymaps/default.json                                            |   1 
assets/keymaps/vim.json                                                |  38 
assets/settings/default.json                                           |   4 
crates/activity_indicator/src/activity_indicator.rs                    |   2 
crates/ai/src/assistant.rs                                             |  18 
crates/auto_update/src/update_notification.rs                          |   4 
crates/breadcrumbs/src/breadcrumbs.rs                                  |   2 
crates/collab/src/tests/integration_tests.rs                           |  42 
crates/collab_ui/src/collab_titlebar_item.rs                           |  30 
crates/collab_ui/src/contact_list.rs                                   |  20 
crates/collab_ui/src/contacts_popover.rs                               |   2 
crates/collab_ui/src/incoming_call_notification.rs                     |   4 
crates/collab_ui/src/notifications.rs                                  |   4 
crates/collab_ui/src/project_shared_notification.rs                    |   4 
crates/collab_ui/src/sharing_status_indicator.rs                       |   2 
crates/context_menu/src/context_menu.rs                                |   4 
crates/copilot/src/sign_in.rs                                          |   2 
crates/copilot_button/src/copilot_button.rs                            |   2 
crates/diagnostics/src/items.rs                                        |   4 
crates/drag_and_drop/src/drag_and_drop.rs                              |   6 
crates/editor/src/display_map.rs                                       |  31 
crates/editor/src/editor.rs                                            |  12 
crates/editor/src/element.rs                                           | 276 
crates/editor/src/hover_popover.rs                                     |   4 
crates/editor/src/inlay_hint_cache.rs                                  | 892 
crates/editor/src/movement.rs                                          |  12 
crates/editor/src/multi_buffer.rs                                      |  19 
crates/editor/src/scroll.rs                                            |   6 
crates/feedback/src/deploy_feedback_button.rs                          |   2 
crates/feedback/src/feedback_info_text.rs                              |   2 
crates/feedback/src/submit_feedback_button.rs                          |   2 
crates/gpui/examples/components.rs                                     | 237 
crates/gpui/src/app.rs                                                 |  38 
crates/gpui/src/elements.rs                                            |  11 
crates/gpui/src/elements/component.rs                                  |  87 
crates/gpui/src/elements/container.rs                                  |   9 
crates/gpui/src/elements/mouse_event_handler.rs                        |  63 
crates/gpui/src/elements/tooltip.rs                                    |   2 
crates/gpui/src/fonts.rs                                               |   7 
crates/gpui/src/platform.rs                                            |  11 
crates/gpui/src/scene/mouse_region.rs                                  |  46 
crates/gpui/src/views/select.rs                                        |   4 
crates/gpui/tests/test.rs                                              |   0 
crates/gpui_macros/Cargo.toml                                          |   3 
crates/language_selector/src/active_buffer_language.rs                 |   2 
crates/language_tools/src/lsp_log.rs                                   |   8 
crates/language_tools/src/syntax_tree_view.rs                          |   8 
crates/lsp/src/lsp.rs                                                  |   4 
crates/picker/src/picker.rs                                            |   2 
crates/project/src/lsp_command.rs                                      |  56 
crates/project_panel/src/project_panel.rs                              |   6 
crates/search/src/buffer_search.rs                                     |   3 
crates/search/src/project_search.rs                                    |   2 
crates/search/src/search_bar.rs                                        |   8 
crates/terminal/src/terminal.rs                                        |   8 
crates/terminal_view/src/terminal_view.rs                              |   6 
crates/theme/src/ui.rs                                                 |  14 
crates/vcs_menu/src/lib.rs                                             |   2 
crates/vim/src/mode_indicator.rs                                       |   2 
crates/vim/src/motion.rs                                               |   7 
crates/vim/src/normal.rs                                               |   2 
crates/vim/src/normal/substitute.rs                                    | 114 
crates/vim/src/object.rs                                               |  85 
crates/vim/src/state.rs                                                |  20 
crates/vim/src/test.rs                                                 |  12 
crates/vim/src/test/neovim_backed_test_context.rs                      |  97 
crates/vim/src/test/neovim_connection.rs                               | 102 
crates/vim/src/test/vim_test_context.rs                                |   5 
crates/vim/src/vim.rs                                                  |  23 
crates/vim/src/visual.rs                                               | 440 
crates/vim/test_data/test_enter_visual_line_mode.json                  |  15 
crates/vim/test_data/test_enter_visual_mode.json                       |  34 
crates/vim/test_data/test_multiline_surrounding_character_objects.json |  10 
crates/vim/test_data/test_visual_change.json                           |  32 
crates/vim/test_data/test_visual_delete.json                           |   4 
crates/vim/test_data/test_visual_line_delete.json                      |   5 
crates/vim/test_data/test_visual_word_object.json                      |  98 
crates/workspace/src/dock.rs                                           |   3 
crates/workspace/src/notifications.rs                                  |   4 
crates/workspace/src/pane.rs                                           |  12 
crates/workspace/src/pane/dragged_item_receiver.rs                     |   4 
crates/workspace/src/pane_group.rs                                     |   2 
crates/workspace/src/shared_screen.rs                                  |   2 
crates/workspace/src/toolbar.rs                                        |  55 
crates/workspace/src/workspace.rs                                      |   4 
crates/zed/src/languages/cpp/config.toml                               |   2 
crates/zed/src/languages/javascript/config.toml                        |   2 
crates/zed/src/languages/python/config.toml                            |   2 
crates/zed/src/languages/typescript/config.toml                        |   2 
90 files changed, 2,195 insertions(+), 1,107 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -3172,7 +3172,6 @@ dependencies = [
 name = "gpui_macros"
 version = "0.1.0"
 dependencies = [
- "gpui",
  "proc-macro2",
  "quote",
  "syn 1.0.109",

assets/keymaps/default.json 🔗

@@ -265,7 +265,6 @@
       "alt-enter": "search::SelectAllMatches",
       "alt-cmd-c": "search::ToggleCaseSensitive",
       "alt-cmd-w": "search::ToggleWholeWord",
-      "alt-cmd-r": "search::ActivateRegexMode",
       "alt-tab": "search::CycleMode",
       "alt-cmd-f": "project_search::ToggleFilters"
     }

assets/keymaps/vim.json 🔗

@@ -101,6 +101,8 @@
         "vim::SwitchMode",
         "Normal"
       ],
+      "v": "vim::ToggleVisual",
+      "shift-v": "vim::ToggleVisualLine",
       "*": "vim::MoveToNext",
       "#": "vim::MoveToPrev",
       "0": "vim::StartOfLine", // When no number operator present, use start of line motion
@@ -236,6 +238,14 @@
       "ctrl-w ctrl-q": "pane::CloseAllItems"
     }
   },
+  {
+    // escape is in its own section so that it cancels a pending count.
+    "context": "Editor && vim_mode == normal && vim_operator == none && !VimWaiting",
+    "bindings": {
+      "escape": "editor::Cancel",
+      "ctrl+[": "editor::Cancel"
+    }
+  },
   {
     "context": "Editor && vim_mode == normal && (vim_operator == none || vim_operator == n) && !VimWaiting",
     "bindings": {
@@ -266,22 +276,6 @@
       "o": "vim::InsertLineBelow",
       "shift-o": "vim::InsertLineAbove",
       "~": "vim::ChangeCase",
-      "v": [
-        "vim::SwitchMode",
-        {
-          "Visual": {
-            "line": false
-          }
-        }
-      ],
-      "shift-v": [
-        "vim::SwitchMode",
-        {
-          "Visual": {
-            "line": true
-          }
-        }
-      ],
       "p": "vim::Paste",
       "u": "editor::Undo",
       "ctrl-r": "editor::Redo",
@@ -374,12 +368,14 @@
     "context": "Editor && vim_mode == visual && !VimWaiting",
     "bindings": {
       "u": "editor::Undo",
-      "c": "vim::VisualChange",
+      "o": "vim::OtherEnd",
+      "shift-o": "vim::OtherEnd",
       "d": "vim::VisualDelete",
       "x": "vim::VisualDelete",
       "y": "vim::VisualYank",
       "p": "vim::VisualPaste",
       "s": "vim::Substitute",
+      "c": "vim::Substitute",
       "~": "vim::ChangeCase",
       "r": [
         "vim::PushOperator",
@@ -389,6 +385,14 @@
         "vim::SwitchMode",
         "Normal"
       ],
+      "escape": [
+        "vim::SwitchMode",
+        "Normal"
+      ],
+      "ctrl+[": [
+        "vim::SwitchMode",
+        "Normal"
+      ],
       ">": "editor::Indent",
       "<": "editor::Outdent"
     }

assets/settings/default.json 🔗

@@ -214,7 +214,9 @@
   "copilot": {
     // The set of glob patterns for which copilot should be disabled
     // in any matching file.
-    "disabled_globs": [".env"]
+    "disabled_globs": [
+      ".env"
+    ]
   },
   // Settings specific to journaling
   "journal": {

crates/activity_indicator/src/activity_indicator.rs 🔗

@@ -318,7 +318,7 @@ impl View for ActivityIndicator {
             on_click,
         } = self.content_to_render(cx);
 
-        let mut element = MouseEventHandler::<Self, _>::new(0, cx, |state, cx| {
+        let mut element = MouseEventHandler::new::<Self, _>(0, cx, |state, cx| {
             let theme = &theme::current(cx).workspace.status_bar.lsp_status;
             let style = if state.hovered() && on_click.is_some() {
                 theme.hovered.as_ref().unwrap_or(&theme.default)

crates/ai/src/assistant.rs 🔗

@@ -348,7 +348,7 @@ impl AssistantPanel {
         enum History {}
         let theme = theme::current(cx);
         let tooltip_style = theme::current(cx).tooltip.clone();
-        MouseEventHandler::<History, _>::new(0, cx, |state, _| {
+        MouseEventHandler::new::<History, _>(0, cx, |state, _| {
             let style = theme.assistant.hamburger_button.style_for(state);
             Svg::for_style(style.icon.clone())
                 .contained()
@@ -380,7 +380,7 @@ impl AssistantPanel {
     fn render_split_button(cx: &mut ViewContext<Self>) -> impl Element<Self> {
         let theme = theme::current(cx);
         let tooltip_style = theme::current(cx).tooltip.clone();
-        MouseEventHandler::<Split, _>::new(0, cx, |state, _| {
+        MouseEventHandler::new::<Split, _>(0, cx, |state, _| {
             let style = theme.assistant.split_button.style_for(state);
             Svg::for_style(style.icon.clone())
                 .contained()
@@ -404,7 +404,7 @@ impl AssistantPanel {
     fn render_assist_button(cx: &mut ViewContext<Self>) -> impl Element<Self> {
         let theme = theme::current(cx);
         let tooltip_style = theme::current(cx).tooltip.clone();
-        MouseEventHandler::<Assist, _>::new(0, cx, |state, _| {
+        MouseEventHandler::new::<Assist, _>(0, cx, |state, _| {
             let style = theme.assistant.assist_button.style_for(state);
             Svg::for_style(style.icon.clone())
                 .contained()
@@ -422,7 +422,7 @@ impl AssistantPanel {
     fn render_quote_button(cx: &mut ViewContext<Self>) -> impl Element<Self> {
         let theme = theme::current(cx);
         let tooltip_style = theme::current(cx).tooltip.clone();
-        MouseEventHandler::<QuoteSelection, _>::new(0, cx, |state, _| {
+        MouseEventHandler::new::<QuoteSelection, _>(0, cx, |state, _| {
             let style = theme.assistant.quote_button.style_for(state);
             Svg::for_style(style.icon.clone())
                 .contained()
@@ -450,7 +450,7 @@ impl AssistantPanel {
     fn render_plus_button(cx: &mut ViewContext<Self>) -> impl Element<Self> {
         let theme = theme::current(cx);
         let tooltip_style = theme::current(cx).tooltip.clone();
-        MouseEventHandler::<NewConversation, _>::new(0, cx, |state, _| {
+        MouseEventHandler::new::<NewConversation, _>(0, cx, |state, _| {
             let style = theme.assistant.plus_button.style_for(state);
             Svg::for_style(style.icon.clone())
                 .contained()
@@ -480,7 +480,7 @@ impl AssistantPanel {
             &theme.assistant.zoom_in_button
         };
 
-        MouseEventHandler::<ToggleZoomButton, _>::new(0, cx, |state, _| {
+        MouseEventHandler::new::<ToggleZoomButton, _>(0, cx, |state, _| {
             let style = style.style_for(state);
             Svg::for_style(style.icon.clone())
                 .contained()
@@ -506,7 +506,7 @@ impl AssistantPanel {
     ) -> impl Element<Self> {
         let conversation = &self.saved_conversations[index];
         let path = conversation.path.clone();
-        MouseEventHandler::<SavedConversationMetadata, _>::new(index, cx, move |state, cx| {
+        MouseEventHandler::new::<SavedConversationMetadata, _>(index, cx, move |state, cx| {
             let style = &theme::current(cx).assistant.saved_conversation;
             Flex::row()
                 .with_child(
@@ -1818,7 +1818,7 @@ impl ConversationEditor {
                             let theme = theme::current(cx);
                             let style = &theme.assistant;
                             let message_id = message.id;
-                            let sender = MouseEventHandler::<Sender, _>::new(
+                            let sender = MouseEventHandler::new::<Sender, _>(
                                 message_id.0,
                                 cx,
                                 |state, _| match message.role {
@@ -2044,7 +2044,7 @@ impl ConversationEditor {
     ) -> impl Element<Self> {
         enum Model {}
 
-        MouseEventHandler::<Model, _>::new(0, cx, |state, cx| {
+        MouseEventHandler::new::<Model, _>(0, cx, |state, cx| {
             let style = style.model.style_for(state);
             Label::new(self.conversation.read(cx).model.clone(), style.text.clone())
                 .contained()

crates/auto_update/src/update_notification.rs 🔗

@@ -31,7 +31,7 @@ impl View for UpdateNotification {
 
         let app_name = cx.global::<ReleaseChannel>().display_name();
 
-        MouseEventHandler::<ViewReleaseNotes, _>::new(0, cx, |state, cx| {
+        MouseEventHandler::new::<ViewReleaseNotes, _>(0, cx, |state, cx| {
             Flex::column()
                 .with_child(
                     Flex::row()
@@ -48,7 +48,7 @@ impl View for UpdateNotification {
                             .flex(1., true),
                         )
                         .with_child(
-                            MouseEventHandler::<Cancel, _>::new(0, cx, |state, _| {
+                            MouseEventHandler::new::<Cancel, _>(0, cx, |state, _| {
                                 let style = theme.dismiss_button.style_for(state);
                                 Svg::new("icons/x_mark_8.svg")
                                     .with_color(style.color)

crates/breadcrumbs/src/breadcrumbs.rs 🔗

@@ -82,7 +82,7 @@ impl View for Breadcrumbs {
                 .into_any();
         }
 
-        MouseEventHandler::<Breadcrumbs, Breadcrumbs>::new(0, cx, |state, _| {
+        MouseEventHandler::new::<Breadcrumbs, _>(0, cx, |state, _| {
             let style = style.style_for(state);
             crumbs.with_style(style.container)
         })

crates/collab/src/tests/integration_tests.rs 🔗

@@ -7953,7 +7953,8 @@ async fn test_mutual_editor_inlay_hint_cache_update(
         );
         let inlay_cache = editor.inlay_hint_cache();
         assert_eq!(
-            inlay_cache.version, edits_made,
+            inlay_cache.version(),
+            edits_made,
             "Host editor update the cache version after every cache/view change",
         );
     });
@@ -7976,7 +7977,8 @@ async fn test_mutual_editor_inlay_hint_cache_update(
         );
         let inlay_cache = editor.inlay_hint_cache();
         assert_eq!(
-            inlay_cache.version, edits_made,
+            inlay_cache.version(),
+            edits_made,
             "Guest editor update the cache version after every cache/view change"
         );
     });
@@ -7996,7 +7998,7 @@ async fn test_mutual_editor_inlay_hint_cache_update(
             "Host should get hints from the 1st edit and 1st LSP query"
         );
         let inlay_cache = editor.inlay_hint_cache();
-        assert_eq!(inlay_cache.version, edits_made);
+        assert_eq!(inlay_cache.version(), edits_made);
     });
     editor_b.update(cx_b, |editor, _| {
         assert_eq!(
@@ -8010,7 +8012,7 @@ async fn test_mutual_editor_inlay_hint_cache_update(
             "Guest should get hints the 1st edit and 2nd LSP query"
         );
         let inlay_cache = editor.inlay_hint_cache();
-        assert_eq!(inlay_cache.version, edits_made);
+        assert_eq!(inlay_cache.version(), edits_made);
     });
 
     editor_a.update(cx_a, |editor, cx| {
@@ -8035,7 +8037,7 @@ async fn test_mutual_editor_inlay_hint_cache_update(
 4th query was made by guest (but not applied) due to cache invalidation logic"
         );
         let inlay_cache = editor.inlay_hint_cache();
-        assert_eq!(inlay_cache.version, edits_made);
+        assert_eq!(inlay_cache.version(), edits_made);
     });
     editor_b.update(cx_b, |editor, _| {
         assert_eq!(
@@ -8051,7 +8053,7 @@ async fn test_mutual_editor_inlay_hint_cache_update(
             "Guest should get hints from 3rd edit, 6th LSP query"
         );
         let inlay_cache = editor.inlay_hint_cache();
-        assert_eq!(inlay_cache.version, edits_made);
+        assert_eq!(inlay_cache.version(), edits_made);
     });
 
     fake_language_server
@@ -8077,7 +8079,8 @@ async fn test_mutual_editor_inlay_hint_cache_update(
         );
         let inlay_cache = editor.inlay_hint_cache();
         assert_eq!(
-            inlay_cache.version, edits_made,
+            inlay_cache.version(),
+            edits_made,
             "Host should accepted all edits and bump its cache version every time"
         );
     });
@@ -8098,7 +8101,7 @@ async fn test_mutual_editor_inlay_hint_cache_update(
         );
         let inlay_cache = editor.inlay_hint_cache();
         assert_eq!(
-            inlay_cache.version,
+            inlay_cache.version(),
             edits_made,
             "Guest should accepted all edits and bump its cache version every time"
         );
@@ -8264,7 +8267,8 @@ async fn test_inlay_hint_refresh_is_forwarded(
         );
         let inlay_cache = editor.inlay_hint_cache();
         assert_eq!(
-            inlay_cache.version, 0,
+            inlay_cache.version(),
+            0,
             "Host should not increment its cache version due to no changes",
         );
     });
@@ -8279,7 +8283,8 @@ async fn test_inlay_hint_refresh_is_forwarded(
         );
         let inlay_cache = editor.inlay_hint_cache();
         assert_eq!(
-            inlay_cache.version, edits_made,
+            inlay_cache.version(),
+            edits_made,
             "Guest editor update the cache version after every cache/view change"
         );
     });
@@ -8296,7 +8301,8 @@ async fn test_inlay_hint_refresh_is_forwarded(
         );
         let inlay_cache = editor.inlay_hint_cache();
         assert_eq!(
-            inlay_cache.version, 0,
+            inlay_cache.version(),
+            0,
             "Host should not increment its cache version due to no changes",
         );
     });
@@ -8311,7 +8317,8 @@ async fn test_inlay_hint_refresh_is_forwarded(
         );
         let inlay_cache = editor.inlay_hint_cache();
         assert_eq!(
-            inlay_cache.version, edits_made,
+            inlay_cache.version(),
+            edits_made,
             "Guest should accepted all edits and bump its cache version every time"
         );
     });
@@ -8343,13 +8350,10 @@ fn room_participants(room: &ModelHandle<Room>, cx: &mut TestAppContext) -> RoomP
 
 fn extract_hint_labels(editor: &Editor) -> Vec<String> {
     let mut labels = Vec::new();
-    for (_, excerpt_hints) in &editor.inlay_hint_cache().hints {
-        let excerpt_hints = excerpt_hints.read();
-        for (_, inlay) in excerpt_hints.hints.iter() {
-            match &inlay.label {
-                project::InlayHintLabel::String(s) => labels.push(s.to_string()),
-                _ => unreachable!(),
-            }
+    for hint in editor.inlay_hint_cache().hints() {
+        match hint.label {
+            project::InlayHintLabel::String(s) => labels.push(s),
+            _ => unreachable!(),
         }
     }
     labels

crates/collab_ui/src/collab_titlebar_item.rs 🔗

@@ -226,7 +226,7 @@ impl CollabTitlebarItem {
         let mut ret = Flex::row().with_child(
             Stack::new()
                 .with_child(
-                    MouseEventHandler::<ToggleProjectMenu, Self>::new(0, cx, |mouse_state, cx| {
+                    MouseEventHandler::new::<ToggleProjectMenu, _>(0, cx, |mouse_state, cx| {
                         let style = project_style
                             .in_state(self.project_popover.is_some())
                             .style_for(mouse_state);
@@ -266,7 +266,7 @@ impl CollabTitlebarItem {
                     .with_child(
                         Stack::new()
                             .with_child(
-                                MouseEventHandler::<ToggleVcsMenu, Self>::new(
+                                MouseEventHandler::new::<ToggleVcsMenu, _>(
                                     0,
                                     cx,
                                     |mouse_state, cx| {
@@ -398,7 +398,7 @@ impl CollabTitlebarItem {
         self.branch_popover.as_ref().map(|child| {
             let theme = theme::current(cx).clone();
             let child = ChildView::new(child, cx);
-            let child = MouseEventHandler::<BranchList, Self>::new(0, cx, |_, _| {
+            let child = MouseEventHandler::new::<BranchList, _>(0, cx, |_, _| {
                 child
                     .flex(1., true)
                     .contained()
@@ -433,7 +433,7 @@ impl CollabTitlebarItem {
         self.project_popover.as_ref().map(|child| {
             let theme = theme::current(cx).clone();
             let child = ChildView::new(child, cx);
-            let child = MouseEventHandler::<RecentProjects, Self>::new(0, cx, |_, _| {
+            let child = MouseEventHandler::new::<RecentProjects, _>(0, cx, |_, _| {
                 child
                     .flex(1., true)
                     .contained()
@@ -560,7 +560,7 @@ impl CollabTitlebarItem {
 
         Stack::new()
             .with_child(
-                MouseEventHandler::<ToggleContactsMenu, Self>::new(0, cx, |state, _| {
+                MouseEventHandler::new::<ToggleContactsMenu, _>(0, cx, |state, _| {
                     let style = titlebar
                         .toggle_contacts_button
                         .in_state(self.contacts_popover.is_some())
@@ -610,7 +610,7 @@ impl CollabTitlebarItem {
 
         let active = room.read(cx).is_screen_sharing();
         let titlebar = &theme.titlebar;
-        MouseEventHandler::<ToggleScreenSharing, Self>::new(0, cx, |state, _| {
+        MouseEventHandler::new::<ToggleScreenSharing, _>(0, cx, |state, _| {
             let style = titlebar
                 .screen_share_button
                 .in_state(active)
@@ -659,7 +659,7 @@ impl CollabTitlebarItem {
         }
 
         let titlebar = &theme.titlebar;
-        MouseEventHandler::<ToggleMute, Self>::new(0, cx, |state, _| {
+        MouseEventHandler::new::<ToggleMute, _>(0, cx, |state, _| {
             let style = titlebar
                 .toggle_microphone_button
                 .in_state(is_muted)
@@ -712,7 +712,7 @@ impl CollabTitlebarItem {
         }
 
         let titlebar = &theme.titlebar;
-        MouseEventHandler::<ToggleDeafen, Self>::new(0, cx, |state, _| {
+        MouseEventHandler::new::<ToggleDeafen, _>(0, cx, |state, _| {
             let style = titlebar
                 .toggle_speakers_button
                 .in_state(is_deafened)
@@ -747,7 +747,7 @@ impl CollabTitlebarItem {
         let tooltip = "Leave call";
 
         let titlebar = &theme.titlebar;
-        MouseEventHandler::<LeaveCall, Self>::new(0, cx, |state, _| {
+        MouseEventHandler::new::<LeaveCall, _>(0, cx, |state, _| {
             let style = titlebar.leave_call_button.style_for(state);
             Svg::new(icon)
                 .with_color(style.color)
@@ -801,7 +801,7 @@ impl CollabTitlebarItem {
         Some(
             Stack::new()
                 .with_child(
-                    MouseEventHandler::<ShareUnshare, Self>::new(0, cx, |state, _| {
+                    MouseEventHandler::new::<ShareUnshare, _>(0, cx, |state, _| {
                         //TODO: Ensure this button has consistent width for both text variations
                         let style = titlebar.share_button.inactive_state().style_for(state);
                         Label::new(label, style.text.clone())
@@ -847,7 +847,7 @@ impl CollabTitlebarItem {
         let avatar_style = &user_menu_button_style.avatar;
         Stack::new()
             .with_child(
-                MouseEventHandler::<ToggleUserMenu, Self>::new(0, cx, |state, _| {
+                MouseEventHandler::new::<ToggleUserMenu, _>(0, cx, |state, _| {
                     let style = user_menu_button_style
                         .user_menu
                         .inactive_state()
@@ -907,7 +907,7 @@ impl CollabTitlebarItem {
 
     fn render_sign_in_button(&self, theme: &Theme, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
         let titlebar = &theme.titlebar;
-        MouseEventHandler::<SignIn, Self>::new(0, cx, |state, _| {
+        MouseEventHandler::new::<SignIn, _>(0, cx, |state, _| {
             let style = titlebar.sign_in_button.inactive_state().style_for(state);
             Label::new("Sign In", style.text.clone())
                 .contained()
@@ -1142,7 +1142,7 @@ impl CollabTitlebarItem {
             if let Some(replica_id) = replica_id {
                 enum ToggleFollow {}
 
-                content = MouseEventHandler::<ToggleFollow, Self>::new(
+                content = MouseEventHandler::new::<ToggleFollow, _>(
                     replica_id.into(),
                     cx,
                     move |_, _| content,
@@ -1173,7 +1173,7 @@ impl CollabTitlebarItem {
                 enum JoinProject {}
 
                 let user_id = user.id;
-                content = MouseEventHandler::<JoinProject, Self>::new(
+                content = MouseEventHandler::new::<JoinProject, _>(
                     peer_id.as_u64() as usize,
                     cx,
                     move |_, _| content,
@@ -1261,7 +1261,7 @@ impl CollabTitlebarItem {
                     .into_any(),
             ),
             client::Status::UpgradeRequired => Some(
-                MouseEventHandler::<ConnectionStatusButton, Self>::new(0, cx, |_, _| {
+                MouseEventHandler::new::<ConnectionStatusButton, _>(0, cx, |_, _| {
                     Label::new(
                         "Please update Zed to collaborate",
                         theme.titlebar.outdated_warning.text.clone(),

crates/collab_ui/src/contact_list.rs 🔗

@@ -810,7 +810,7 @@ impl ContactList {
             worktree_root_names.join(", ")
         };
 
-        MouseEventHandler::<JoinProject, Self>::new(project_id as usize, cx, |mouse_state, _| {
+        MouseEventHandler::new::<JoinProject, _>(project_id as usize, cx, |mouse_state, _| {
             let tree_branch = *tree_branch.in_state(is_selected).style_for(mouse_state);
             let row = theme
                 .project_row
@@ -904,7 +904,7 @@ impl ContactList {
         let baseline_offset =
             row.name.text.baseline_offset(font_cache) + (theme.row_height - line_height) / 2.;
 
-        MouseEventHandler::<OpenSharedScreen, Self>::new(
+        MouseEventHandler::new::<OpenSharedScreen, _>(
             peer_id.as_u64() as usize,
             cx,
             |mouse_state, _| {
@@ -1006,7 +1006,7 @@ impl ContactList {
         };
         let leave_call = if section == Section::ActiveCall {
             Some(
-                MouseEventHandler::<LeaveCallContactList, Self>::new(0, cx, |state, _| {
+                MouseEventHandler::new::<LeaveCallContactList, _>(0, cx, |state, _| {
                     let style = theme.leave_call.style_for(state);
                     Label::new("Leave Call", style.text.clone())
                         .contained()
@@ -1024,7 +1024,7 @@ impl ContactList {
         };
 
         let icon_size = theme.section_icon_size;
-        MouseEventHandler::<Header, Self>::new(section as usize, cx, |_, _| {
+        MouseEventHandler::new::<Header, _>(section as usize, cx, |_, _| {
             Flex::row()
                 .with_child(
                     Svg::new(if is_collapsed {
@@ -1075,7 +1075,7 @@ impl ContactList {
         let github_login = contact.user.github_login.clone();
         let initial_project = project.clone();
         let mut event_handler =
-            MouseEventHandler::<Contact, Self>::new(contact.user.id as usize, cx, |_, cx| {
+            MouseEventHandler::new::<Contact, _>(contact.user.id as usize, cx, |_, cx| {
                 Flex::row()
                     .with_children(contact.user.avatar.clone().map(|avatar| {
                         let status_badge = if contact.online {
@@ -1114,7 +1114,7 @@ impl ContactList {
                         .flex(1., true),
                     )
                     .with_child(
-                        MouseEventHandler::<Cancel, Self>::new(
+                        MouseEventHandler::new::<Cancel, _>(
                             contact.user.id as usize,
                             cx,
                             |mouse_state, _| {
@@ -1208,7 +1208,7 @@ impl ContactList {
 
         if is_incoming {
             row.add_child(
-                MouseEventHandler::<Decline, Self>::new(user.id as usize, cx, |mouse_state, _| {
+                MouseEventHandler::new::<Decline, _>(user.id as usize, cx, |mouse_state, _| {
                     let button_style = if is_contact_request_pending {
                         &theme.disabled_button
                     } else {
@@ -1231,7 +1231,7 @@ impl ContactList {
             );
 
             row.add_child(
-                MouseEventHandler::<Accept, Self>::new(user.id as usize, cx, |mouse_state, _| {
+                MouseEventHandler::new::<Accept, _>(user.id as usize, cx, |mouse_state, _| {
                     let button_style = if is_contact_request_pending {
                         &theme.disabled_button
                     } else {
@@ -1254,7 +1254,7 @@ impl ContactList {
             );
         } else {
             row.add_child(
-                MouseEventHandler::<Cancel, Self>::new(user.id as usize, cx, |mouse_state, _| {
+                MouseEventHandler::new::<Cancel, _>(user.id as usize, cx, |mouse_state, _| {
                     let button_style = if is_contact_request_pending {
                         &theme.disabled_button
                     } else {
@@ -1333,7 +1333,7 @@ impl View for ContactList {
                             .flex(1., true),
                     )
                     .with_child(
-                        MouseEventHandler::<AddContact, Self>::new(0, cx, |_, _| {
+                        MouseEventHandler::new::<AddContact, _>(0, cx, |_, _| {
                             render_icon_button(
                                 &theme.contact_list.add_contact_button,
                                 "icons/user_plus_16.svg",

crates/collab_ui/src/contacts_popover.rs 🔗

@@ -113,7 +113,7 @@ impl View for ContactsPopover {
             Child::ContactFinder(child) => ChildView::new(child, cx),
         };
 
-        MouseEventHandler::<ContactsPopover, Self>::new(0, cx, |_, _| {
+        MouseEventHandler::new::<ContactsPopover, _>(0, cx, |_, _| {
             Flex::column()
                 .with_child(child.flex(1., true))
                 .contained()

crates/collab_ui/src/incoming_call_notification.rs 🔗

@@ -173,7 +173,7 @@ impl IncomingCallNotification {
         let theme = theme::current(cx);
         Flex::column()
             .with_child(
-                MouseEventHandler::<Accept, Self>::new(0, cx, |_, _| {
+                MouseEventHandler::new::<Accept, _>(0, cx, |_, _| {
                     let theme = &theme.incoming_call_notification;
                     Label::new("Accept", theme.accept_button.text.clone())
                         .aligned()
@@ -187,7 +187,7 @@ impl IncomingCallNotification {
                 .flex(1., true),
             )
             .with_child(
-                MouseEventHandler::<Decline, Self>::new(0, cx, |_, _| {
+                MouseEventHandler::new::<Decline, _>(0, cx, |_, _| {
                     let theme = &theme.incoming_call_notification;
                     Label::new("Decline", theme.decline_button.text.clone())
                         .aligned()

crates/collab_ui/src/notifications.rs 🔗

@@ -52,7 +52,7 @@ where
                     .flex(1., true),
                 )
                 .with_child(
-                    MouseEventHandler::<Dismiss, V>::new(user.id as usize, cx, |state, _| {
+                    MouseEventHandler::new::<Dismiss, _>(user.id as usize, cx, |state, _| {
                         let style = theme.dismiss_button.style_for(state);
                         Svg::new("icons/x_mark_8.svg")
                             .with_color(style.color)
@@ -92,7 +92,7 @@ where
                 Flex::row()
                     .with_children(buttons.into_iter().enumerate().map(
                         |(ix, (message, handler))| {
-                            MouseEventHandler::<Button, V>::new(ix, cx, |state, _| {
+                            MouseEventHandler::new::<Button, _>(ix, cx, |state, _| {
                                 let button = theme.button.style_for(state);
                                 Label::new(message, button.text.clone())
                                     .contained()

crates/collab_ui/src/project_shared_notification.rs 🔗

@@ -170,7 +170,7 @@ impl ProjectSharedNotification {
         let theme = theme::current(cx);
         Flex::column()
             .with_child(
-                MouseEventHandler::<Open, Self>::new(0, cx, |_, _| {
+                MouseEventHandler::new::<Open, _>(0, cx, |_, _| {
                     let theme = &theme.project_shared_notification;
                     Label::new("Open", theme.open_button.text.clone())
                         .aligned()
@@ -182,7 +182,7 @@ impl ProjectSharedNotification {
                 .flex(1., true),
             )
             .with_child(
-                MouseEventHandler::<Dismiss, Self>::new(0, cx, |_, _| {
+                MouseEventHandler::new::<Dismiss, _>(0, cx, |_, _| {
                     let theme = &theme.project_shared_notification;
                     Label::new("Dismiss", theme.dismiss_button.text.clone())
                         .aligned()

crates/collab_ui/src/sharing_status_indicator.rs 🔗

@@ -47,7 +47,7 @@ impl View for SharingStatusIndicator {
             Appearance::Dark | Appearance::VibrantDark => Color::white(),
         };
 
-        MouseEventHandler::<Self, Self>::new(0, cx, |_, _| {
+        MouseEventHandler::new::<Self, _>(0, cx, |_, _| {
             Svg::new("icons/disable_screen_sharing_12.svg")
                 .with_color(color)
                 .constrained()

crates/context_menu/src/context_menu.rs 🔗

@@ -439,14 +439,14 @@ impl ContextMenu {
 
         let style = theme::current(cx).context_menu.clone();
 
-        MouseEventHandler::<Menu, ContextMenu>::new(0, cx, |_, cx| {
+        MouseEventHandler::new::<Menu, _>(0, cx, |_, cx| {
             Flex::column()
                 .with_children(self.items.iter().enumerate().map(|(ix, item)| {
                     match item {
                         ContextMenuItem::Item { label, action } => {
                             let action = action.clone();
                             let view_id = self.parent_view_id;
-                            MouseEventHandler::<MenuItem, ContextMenu>::new(ix, cx, |state, _| {
+                            MouseEventHandler::new::<MenuItem, _>(ix, cx, |state, _| {
                                 let style = style.item.in_state(self.selected_index == Some(ix));
                                 let style = style.style_for(state);
                                 let keystroke = match &action {

crates/copilot/src/sign_in.rs 🔗

@@ -113,7 +113,7 @@ impl CopilotCodeVerification {
 
         let device_code_style = &style.auth.prompting.device_code;
 
-        MouseEventHandler::<Self, _>::new(0, cx, |state, _cx| {
+        MouseEventHandler::new::<Self, _>(0, cx, |state, _cx| {
             Flex::row()
                 .with_child(
                     Label::new(data.user_code.clone(), device_code_style.text.clone())

crates/copilot_button/src/copilot_button.rs 🔗

@@ -62,7 +62,7 @@ impl View for CopilotButton {
 
         Stack::new()
             .with_child(
-                MouseEventHandler::<Self, _>::new(0, cx, {
+                MouseEventHandler::new::<Self, _>(0, cx, {
                     let theme = theme.clone();
                     let status = status.clone();
                     move |state, _cx| {

crates/diagnostics/src/items.rs 🔗

@@ -94,7 +94,7 @@ impl View for DiagnosticIndicator {
         let tooltip_style = theme::current(cx).tooltip.clone();
         let in_progress = !self.in_progress_checks.is_empty();
         let mut element = Flex::row().with_child(
-            MouseEventHandler::<Summary, _>::new(0, cx, |state, cx| {
+            MouseEventHandler::new::<Summary, _>(0, cx, |state, cx| {
                 let theme = theme::current(cx);
                 let style = theme
                     .workspace
@@ -195,7 +195,7 @@ impl View for DiagnosticIndicator {
         } else if let Some(diagnostic) = &self.current_diagnostic {
             let message_style = style.diagnostic_message.clone();
             element.add_child(
-                MouseEventHandler::<Message, _>::new(1, cx, |state, _| {
+                MouseEventHandler::new::<Message, _>(1, cx, |state, _| {
                     Label::new(
                         diagnostic.message.split('\n').next().unwrap().to_string(),
                         message_style.style_for(state).text.clone(),

crates/drag_and_drop/src/drag_and_drop.rs 🔗

@@ -202,7 +202,7 @@ impl<V: View> DragAndDrop<V> {
                         let position = (position - region_offset).round();
                         Some(
                             Overlay::new(
-                                MouseEventHandler::<DraggedElementHandler, V>::new(
+                                MouseEventHandler::new::<DraggedElementHandler, _>(
                                     0,
                                     cx,
                                     |_, cx| render(payload, cx),
@@ -235,7 +235,7 @@ impl<V: View> DragAndDrop<V> {
                     }
 
                     State::Canceled => Some(
-                        MouseEventHandler::<DraggedElementHandler, V>::new(0, cx, |_, _| {
+                        MouseEventHandler::new::<DraggedElementHandler, _>(0, cx, |_, _| {
                             Empty::new().constrained().with_width(0.).with_height(0.)
                         })
                         .on_up(MouseButton::Left, |_, _, cx| {
@@ -301,7 +301,7 @@ pub trait Draggable<V: View> {
         Self: Sized;
 }
 
-impl<Tag, V: View> Draggable<V> for MouseEventHandler<Tag, V> {
+impl<V: View> Draggable<V> for MouseEventHandler<V> {
     fn as_draggable<D: View, P: Any>(
         self,
         payload: P,

crates/editor/src/display_map.rs 🔗

@@ -353,19 +353,26 @@ impl DisplaySnapshot {
         }
     }
 
+    // used by line_mode selections and tries to match vim behaviour
     pub fn expand_to_line(&self, range: Range<Point>) -> Range<Point> {
-        let mut new_start = self.prev_line_boundary(range.start).0;
-        let mut new_end = self.next_line_boundary(range.end).0;
-
-        if new_start.row == range.start.row && new_end.row == range.end.row {
-            if new_end.row < self.buffer_snapshot.max_point().row {
-                new_end.row += 1;
-                new_end.column = 0;
-            } else if new_start.row > 0 {
-                new_start.row -= 1;
-                new_start.column = self.buffer_snapshot.line_len(new_start.row);
-            }
-        }
+        let new_start = if range.start.row == 0 {
+            Point::new(0, 0)
+        } else if range.start.row == self.max_buffer_row()
+            || (range.end.column > 0 && range.end.row == self.max_buffer_row())
+        {
+            Point::new(range.start.row - 1, self.line_len(range.start.row - 1))
+        } else {
+            self.prev_line_boundary(range.start).0
+        };
+
+        let new_end = if range.end.column == 0 {
+            range.end
+        } else if range.end.row < self.max_buffer_row() {
+            self.buffer_snapshot
+                .clip_point(Point::new(range.end.row + 1, 0), Bias::Left)
+        } else {
+            self.buffer_snapshot.max_point()
+        };
 
         new_start..new_end
     }

crates/editor/src/editor.rs 🔗

@@ -867,7 +867,7 @@ impl CompletionsMenu {
                     let completion = &completions[mat.candidate_id];
                     let item_ix = start_ix + ix;
                     items.push(
-                        MouseEventHandler::<CompletionTag, _>::new(
+                        MouseEventHandler::new::<CompletionTag, _>(
                             mat.candidate_id,
                             cx,
                             |state, _| {
@@ -1044,7 +1044,7 @@ impl CodeActionsMenu {
                 for (ix, action) in actions[range].iter().enumerate() {
                     let item_ix = start_ix + ix;
                     items.push(
-                        MouseEventHandler::<ActionTag, _>::new(item_ix, cx, |state, _| {
+                        MouseEventHandler::new::<ActionTag, _>(item_ix, cx, |state, _| {
                             let item_style = if item_ix == selected_item {
                                 style.autocomplete.selected_item
                             } else if state.hovered() {
@@ -2723,7 +2723,7 @@ impl Editor {
             .collect()
     }
 
-    fn excerpt_visible_offsets(
+    pub fn excerpt_visible_offsets(
         &self,
         restrict_to_languages: Option<&HashSet<Arc<Language>>>,
         cx: &mut ViewContext<'_, '_, Editor>,
@@ -3547,7 +3547,7 @@ impl Editor {
         if self.available_code_actions.is_some() {
             enum CodeActions {}
             Some(
-                MouseEventHandler::<CodeActions, _>::new(0, cx, |state, _| {
+                MouseEventHandler::new::<CodeActions, _>(0, cx, |state, _| {
                     Svg::new("icons/bolt_8.svg").with_color(
                         style
                             .code_actions
@@ -3594,7 +3594,7 @@ impl Editor {
                 fold_data
                     .map(|(fold_status, buffer_row, active)| {
                         (active || gutter_hovered || fold_status == FoldStatus::Folded).then(|| {
-                            MouseEventHandler::<FoldIndicators, _>::new(
+                            MouseEventHandler::new::<FoldIndicators, _>(
                                 ix as usize,
                                 cx,
                                 |mouse_state, _| {
@@ -8663,7 +8663,7 @@ pub fn diagnostic_block_renderer(diagnostic: Diagnostic, is_valid: bool) -> Rend
         let font_size = (style.text_scale_factor * settings.buffer_font_size(cx)).round();
         let anchor_x = cx.anchor_x;
         enum BlockContextToolip {}
-        MouseEventHandler::<BlockContext, _>::new(cx.block_id, cx, |_, _| {
+        MouseEventHandler::new::<BlockContext, _>(cx.block_id, cx, |_, _| {
             Flex::column()
                 .with_children(highlighted_lines.iter().map(|(line, highlights)| {
                     Label::new(

crates/editor/src/element.rs 🔗

@@ -63,6 +63,7 @@ struct SelectionLayout {
     cursor_shape: CursorShape,
     is_newest: bool,
     range: Range<DisplayPoint>,
+    active_rows: Range<u32>,
 }
 
 impl SelectionLayout {
@@ -73,25 +74,44 @@ impl SelectionLayout {
         map: &DisplaySnapshot,
         is_newest: bool,
     ) -> Self {
+        let point_selection = selection.map(|p| p.to_point(&map.buffer_snapshot));
+        let display_selection = point_selection.map(|p| p.to_display_point(map));
+        let mut range = display_selection.range();
+        let mut head = display_selection.head();
+        let mut active_rows = map.prev_line_boundary(point_selection.start).1.row()
+            ..map.next_line_boundary(point_selection.end).1.row();
+
+        // vim visual line mode
         if line_mode {
-            let selection = selection.map(|p| p.to_point(&map.buffer_snapshot));
-            let point_range = map.expand_to_line(selection.range());
-            Self {
-                head: selection.head().to_display_point(map),
-                cursor_shape,
-                is_newest,
-                range: point_range.start.to_display_point(map)
-                    ..point_range.end.to_display_point(map),
-            }
-        } else {
-            let selection = selection.map(|p| p.to_display_point(map));
-            Self {
-                head: selection.head(),
-                cursor_shape,
-                is_newest,
-                range: selection.range(),
+            let point_range = map.expand_to_line(point_selection.range());
+            range = point_range.start.to_display_point(map)..point_range.end.to_display_point(map);
+        }
+
+        // any vim visual mode (including line mode)
+        if cursor_shape == CursorShape::Block && !range.is_empty() && !selection.reversed {
+            if head.column() > 0 {
+                head = map.clip_point(DisplayPoint::new(head.row(), head.column() - 1), Bias::Left)
+            } else if head.row() > 0 && head != map.max_point() {
+                head = map.clip_point(
+                    DisplayPoint::new(head.row() - 1, map.line_len(head.row() - 1)),
+                    Bias::Left,
+                );
+                // updating range.end is a no-op unless you're cursor is
+                // on the newline containing a multi-buffer divider
+                // in which case the clip_point may have moved the head up
+                // an additional row.
+                range.end = DisplayPoint::new(head.row() + 1, 0);
+                active_rows.end = head.row();
             }
         }
+
+        Self {
+            head,
+            cursor_shape,
+            is_newest,
+            range,
+            active_rows,
+        }
     }
 }
 
@@ -1637,7 +1657,7 @@ impl EditorElement {
                         let jump_position = language::ToPoint::to_point(&jump_anchor, buffer);
 
                         enum JumpIcon {}
-                        MouseEventHandler::<JumpIcon, _>::new((*id).into(), cx, |state, _| {
+                        MouseEventHandler::new::<JumpIcon, _>((*id).into(), cx, |state, _| {
                             let style = style.jump_icon.style_for(state);
                             Svg::new("icons/arrow_up_right_8.svg")
                                 .with_color(style.color)
@@ -2152,22 +2172,37 @@ impl Element<Editor> for EditorElement {
         }
         selections.extend(remote_selections);
 
+        let mut newest_selection_head = None;
+
         if editor.show_local_selections {
-            let mut local_selections = editor
+            let mut local_selections: Vec<Selection<Point>> = editor
                 .selections
                 .disjoint_in_range(start_anchor..end_anchor, cx);
             local_selections.extend(editor.selections.pending(cx));
+            let mut layouts = Vec::new();
             let newest = editor.selections.newest(cx);
-            for selection in &local_selections {
+            for selection in local_selections.drain(..) {
                 let is_empty = selection.start == selection.end;
-                let selection_start = snapshot.prev_line_boundary(selection.start).1;
-                let selection_end = snapshot.next_line_boundary(selection.end).1;
-                for row in cmp::max(selection_start.row(), start_row)
-                    ..=cmp::min(selection_end.row(), end_row)
+                let is_newest = selection == newest;
+
+                let layout = SelectionLayout::new(
+                    selection,
+                    editor.selections.line_mode,
+                    editor.cursor_shape,
+                    &snapshot.display_snapshot,
+                    is_newest,
+                );
+                if is_newest {
+                    newest_selection_head = Some(layout.head);
+                }
+
+                for row in cmp::max(layout.active_rows.start, start_row)
+                    ..=cmp::min(layout.active_rows.end, end_row)
                 {
                     let contains_non_empty_selection = active_rows.entry(row).or_insert(!is_empty);
                     *contains_non_empty_selection |= !is_empty;
                 }
+                layouts.push(layout);
             }
 
             // Render the local selections in the leader's color when following.
@@ -2175,22 +2210,7 @@ impl Element<Editor> for EditorElement {
                 .leader_replica_id
                 .unwrap_or_else(|| editor.replica_id(cx));
 
-            selections.push((
-                local_replica_id,
-                local_selections
-                    .into_iter()
-                    .map(|selection| {
-                        let is_newest = selection == newest;
-                        SelectionLayout::new(
-                            selection,
-                            editor.selections.line_mode,
-                            editor.cursor_shape,
-                            &snapshot.display_snapshot,
-                            is_newest,
-                        )
-                    })
-                    .collect(),
-            ));
+            selections.push((local_replica_id, layouts));
         }
 
         let scrollbar_settings = &settings::get::<EditorSettings>(cx).scrollbar;
@@ -2295,28 +2315,26 @@ impl Element<Editor> for EditorElement {
             snapshot = editor.snapshot(cx);
         }
 
-        let newest_selection_head = editor
-            .selections
-            .newest::<usize>(cx)
-            .head()
-            .to_display_point(&snapshot);
         let style = editor.style(cx);
 
         let mut context_menu = None;
         let mut code_actions_indicator = None;
-        if (start_row..end_row).contains(&newest_selection_head.row()) {
-            if editor.context_menu_visible() {
-                context_menu = editor.render_context_menu(newest_selection_head, style.clone(), cx);
-            }
+        if let Some(newest_selection_head) = newest_selection_head {
+            if (start_row..end_row).contains(&newest_selection_head.row()) {
+                if editor.context_menu_visible() {
+                    context_menu =
+                        editor.render_context_menu(newest_selection_head, style.clone(), cx);
+                }
 
-            let active = matches!(
-                editor.context_menu,
-                Some(crate::ContextMenu::CodeActions(_))
-            );
+                let active = matches!(
+                    editor.context_menu,
+                    Some(crate::ContextMenu::CodeActions(_))
+                );
 
-            code_actions_indicator = editor
-                .render_code_actions_indicator(&style, active, cx)
-                .map(|indicator| (newest_selection_head.row(), indicator));
+                code_actions_indicator = editor
+                    .render_code_actions_indicator(&style, active, cx)
+                    .map(|indicator| (newest_selection_head.row(), indicator));
+            }
         }
 
         let visible_rows = start_row..start_row + line_layouts.len() as u32;
@@ -2995,6 +3013,154 @@ mod tests {
         assert_eq!(layouts.len(), 6);
     }
 
+    #[gpui::test]
+    async fn test_vim_visual_selections(cx: &mut TestAppContext) {
+        init_test(cx, |_| {});
+
+        let editor = cx
+            .add_window(|cx| {
+                let buffer = MultiBuffer::build_simple(&(sample_text(6, 6, 'a') + "\n"), cx);
+                Editor::new(EditorMode::Full, buffer, None, None, cx)
+            })
+            .root(cx);
+        let mut element = EditorElement::new(editor.read_with(cx, |editor, cx| editor.style(cx)));
+        let (_, state) = editor.update(cx, |editor, cx| {
+            editor.cursor_shape = CursorShape::Block;
+            editor.change_selections(None, cx, |s| {
+                s.select_ranges([
+                    Point::new(0, 0)..Point::new(1, 0),
+                    Point::new(3, 2)..Point::new(3, 3),
+                    Point::new(5, 6)..Point::new(6, 0),
+                ]);
+            });
+            let mut new_parents = Default::default();
+            let mut notify_views_if_parents_change = Default::default();
+            let mut layout_cx = LayoutContext::new(
+                cx,
+                &mut new_parents,
+                &mut notify_views_if_parents_change,
+                false,
+            );
+            element.layout(
+                SizeConstraint::new(vec2f(500., 500.), vec2f(500., 500.)),
+                editor,
+                &mut layout_cx,
+            )
+        });
+        assert_eq!(state.selections.len(), 1);
+        let local_selections = &state.selections[0].1;
+        assert_eq!(local_selections.len(), 3);
+        // moves cursor back one line
+        assert_eq!(local_selections[0].head, DisplayPoint::new(0, 6));
+        assert_eq!(
+            local_selections[0].range,
+            DisplayPoint::new(0, 0)..DisplayPoint::new(1, 0)
+        );
+
+        // moves cursor back one column
+        assert_eq!(
+            local_selections[1].range,
+            DisplayPoint::new(3, 2)..DisplayPoint::new(3, 3)
+        );
+        assert_eq!(local_selections[1].head, DisplayPoint::new(3, 2));
+
+        // leaves cursor on the max point
+        assert_eq!(
+            local_selections[2].range,
+            DisplayPoint::new(5, 6)..DisplayPoint::new(6, 0)
+        );
+        assert_eq!(local_selections[2].head, DisplayPoint::new(6, 0));
+
+        // active lines does not include 1 (even though the range of the selection does)
+        assert_eq!(
+            state.active_rows.keys().cloned().collect::<Vec<u32>>(),
+            vec![0, 3, 5, 6]
+        );
+
+        // multi-buffer support
+        // in DisplayPoint co-ordinates, this is what we're dealing with:
+        //  0: [[file
+        //  1:   header]]
+        //  2: aaaaaa
+        //  3: bbbbbb
+        //  4: cccccc
+        //  5:
+        //  6: ...
+        //  7: ffffff
+        //  8: gggggg
+        //  9: hhhhhh
+        // 10:
+        // 11: [[file
+        // 12:   header]]
+        // 13: bbbbbb
+        // 14: cccccc
+        // 15: dddddd
+        let editor = cx
+            .add_window(|cx| {
+                let buffer = MultiBuffer::build_multi(
+                    [
+                        (
+                            &(sample_text(8, 6, 'a') + "\n"),
+                            vec![
+                                Point::new(0, 0)..Point::new(3, 0),
+                                Point::new(4, 0)..Point::new(7, 0),
+                            ],
+                        ),
+                        (
+                            &(sample_text(8, 6, 'a') + "\n"),
+                            vec![Point::new(1, 0)..Point::new(3, 0)],
+                        ),
+                    ],
+                    cx,
+                );
+                Editor::new(EditorMode::Full, buffer, None, None, cx)
+            })
+            .root(cx);
+        let mut element = EditorElement::new(editor.read_with(cx, |editor, cx| editor.style(cx)));
+        let (_, state) = editor.update(cx, |editor, cx| {
+            editor.cursor_shape = CursorShape::Block;
+            editor.change_selections(None, cx, |s| {
+                s.select_display_ranges([
+                    DisplayPoint::new(4, 0)..DisplayPoint::new(7, 0),
+                    DisplayPoint::new(10, 0)..DisplayPoint::new(13, 0),
+                ]);
+            });
+            let mut new_parents = Default::default();
+            let mut notify_views_if_parents_change = Default::default();
+            let mut layout_cx = LayoutContext::new(
+                cx,
+                &mut new_parents,
+                &mut notify_views_if_parents_change,
+                false,
+            );
+            element.layout(
+                SizeConstraint::new(vec2f(500., 500.), vec2f(500., 500.)),
+                editor,
+                &mut layout_cx,
+            )
+        });
+
+        assert_eq!(state.selections.len(), 1);
+        let local_selections = &state.selections[0].1;
+        assert_eq!(local_selections.len(), 2);
+
+        // moves cursor on excerpt boundary back a line
+        // and doesn't allow selection to bleed through
+        assert_eq!(
+            local_selections[0].range,
+            DisplayPoint::new(4, 0)..DisplayPoint::new(6, 0)
+        );
+        assert_eq!(local_selections[0].head, DisplayPoint::new(5, 0));
+
+        // moves cursor on buffer boundary back two lines
+        // and doesn't allow selection to bleed through
+        assert_eq!(
+            local_selections[1].range,
+            DisplayPoint::new(10, 0)..DisplayPoint::new(11, 0)
+        );
+        assert_eq!(local_selections[1].head, DisplayPoint::new(10, 0));
+    }
+
     #[gpui::test]
     fn test_layout_with_placeholder_text_and_blocks(cx: &mut TestAppContext) {
         init_test(cx, |_| {});

crates/editor/src/hover_popover.rs 🔗

@@ -565,7 +565,7 @@ impl InfoPopover {
             )
         });
 
-        MouseEventHandler::<InfoPopover, _>::new(0, cx, |_, cx| {
+        MouseEventHandler::new::<InfoPopover, _>(0, cx, |_, cx| {
             let mut region_id = 0;
             let view_id = cx.view_id();
 
@@ -654,7 +654,7 @@ impl DiagnosticPopover {
 
         let tooltip_style = theme::current(cx).tooltip.clone();
 
-        MouseEventHandler::<DiagnosticPopover, _>::new(0, cx, |_, _| {
+        MouseEventHandler::new::<DiagnosticPopover, _>(0, cx, |_, _| {
             text.with_soft_wrap(true)
                 .contained()
                 .with_style(container_style)

crates/editor/src/inlay_hint_cache.rs 🔗

@@ -9,7 +9,7 @@ use crate::{
 };
 use anyhow::Context;
 use clock::Global;
-use gpui::{ModelHandle, Task, ViewContext};
+use gpui::{ModelContext, ModelHandle, Task, ViewContext};
 use language::{language_settings::InlayHintKind, Buffer, BufferSnapshot};
 use log::error;
 use parking_lot::RwLock;
@@ -17,14 +17,21 @@ use project::InlayHint;
 
 use collections::{hash_map, HashMap, HashSet};
 use language::language_settings::InlayHintSettings;
+use sum_tree::Bias;
 use util::post_inc;
 
 pub struct InlayHintCache {
-    pub hints: HashMap<ExcerptId, Arc<RwLock<CachedExcerptHints>>>,
-    pub allowed_hint_kinds: HashSet<Option<InlayHintKind>>,
-    pub version: usize,
-    pub enabled: bool,
-    update_tasks: HashMap<ExcerptId, UpdateTask>,
+    hints: HashMap<ExcerptId, Arc<RwLock<CachedExcerptHints>>>,
+    allowed_hint_kinds: HashSet<Option<InlayHintKind>>,
+    version: usize,
+    enabled: bool,
+    update_tasks: HashMap<ExcerptId, TasksForRanges>,
+}
+
+#[derive(Debug)]
+struct TasksForRanges {
+    tasks: Vec<Task<()>>,
+    sorted_ranges: Vec<Range<language::Anchor>>,
 }
 
 #[derive(Debug)]
@@ -32,7 +39,7 @@ pub struct CachedExcerptHints {
     version: usize,
     buffer_version: Global,
     buffer_id: u64,
-    pub hints: Vec<(InlayId, InlayHint)>,
+    hints: Vec<(InlayId, InlayHint)>,
 }
 
 #[derive(Debug, Clone, Copy)]
@@ -48,18 +55,6 @@ pub struct InlaySplice {
     pub to_insert: Vec<Inlay>,
 }
 
-struct UpdateTask {
-    invalidate: InvalidationStrategy,
-    cache_version: usize,
-    task: RunningTask,
-    pending_refresh: Option<ExcerptQuery>,
-}
-
-struct RunningTask {
-    _task: Task<()>,
-    is_running_rx: smol::channel::Receiver<()>,
-}
-
 #[derive(Debug)]
 struct ExcerptHintsUpdate {
     excerpt_id: ExcerptId,
@@ -72,24 +67,10 @@ struct ExcerptHintsUpdate {
 struct ExcerptQuery {
     buffer_id: u64,
     excerpt_id: ExcerptId,
-    dimensions: ExcerptDimensions,
     cache_version: usize,
     invalidate: InvalidationStrategy,
 }
 
-#[derive(Debug, Clone, Copy)]
-struct ExcerptDimensions {
-    excerpt_range_start: language::Anchor,
-    excerpt_range_end: language::Anchor,
-    excerpt_visible_range_start: language::Anchor,
-    excerpt_visible_range_end: language::Anchor,
-}
-
-struct HintFetchRanges {
-    visible_range: Range<language::Anchor>,
-    other_ranges: Vec<Range<language::Anchor>>,
-}
-
 impl InvalidationStrategy {
     fn should_invalidate(&self) -> bool {
         matches!(
@@ -99,35 +80,92 @@ impl InvalidationStrategy {
     }
 }
 
-impl ExcerptQuery {
-    fn hints_fetch_ranges(&self, buffer: &BufferSnapshot) -> HintFetchRanges {
-        let visible_range =
-            self.dimensions.excerpt_visible_range_start..self.dimensions.excerpt_visible_range_end;
-        let mut other_ranges = Vec::new();
-        if self
-            .dimensions
-            .excerpt_range_start
-            .cmp(&visible_range.start, buffer)
-            .is_lt()
-        {
-            let mut end = visible_range.start;
-            end.offset -= 1;
-            other_ranges.push(self.dimensions.excerpt_range_start..end);
-        }
-        if self
-            .dimensions
-            .excerpt_range_end
-            .cmp(&visible_range.end, buffer)
-            .is_gt()
-        {
-            let mut start = visible_range.end;
-            start.offset += 1;
-            other_ranges.push(start..self.dimensions.excerpt_range_end);
+impl TasksForRanges {
+    fn new(sorted_ranges: Vec<Range<language::Anchor>>, task: Task<()>) -> Self {
+        Self {
+            tasks: vec![task],
+            sorted_ranges,
         }
+    }
 
-        HintFetchRanges {
-            visible_range,
-            other_ranges: other_ranges.into_iter().map(|range| range).collect(),
+    fn update_cached_tasks(
+        &mut self,
+        buffer_snapshot: &BufferSnapshot,
+        query_range: Range<text::Anchor>,
+        invalidate: InvalidationStrategy,
+        spawn_task: impl FnOnce(Vec<Range<language::Anchor>>) -> Task<()>,
+    ) {
+        let ranges_to_query = match invalidate {
+            InvalidationStrategy::None => {
+                let mut ranges_to_query = Vec::new();
+                let mut latest_cached_range = None::<&mut Range<language::Anchor>>;
+                for cached_range in self
+                    .sorted_ranges
+                    .iter_mut()
+                    .skip_while(|cached_range| {
+                        cached_range
+                            .end
+                            .cmp(&query_range.start, buffer_snapshot)
+                            .is_lt()
+                    })
+                    .take_while(|cached_range| {
+                        cached_range
+                            .start
+                            .cmp(&query_range.end, buffer_snapshot)
+                            .is_le()
+                    })
+                {
+                    match latest_cached_range {
+                        Some(latest_cached_range) => {
+                            if latest_cached_range.end.offset.saturating_add(1)
+                                < cached_range.start.offset
+                            {
+                                ranges_to_query.push(latest_cached_range.end..cached_range.start);
+                                cached_range.start = latest_cached_range.end;
+                            }
+                        }
+                        None => {
+                            if query_range
+                                .start
+                                .cmp(&cached_range.start, buffer_snapshot)
+                                .is_lt()
+                            {
+                                ranges_to_query.push(query_range.start..cached_range.start);
+                                cached_range.start = query_range.start;
+                            }
+                        }
+                    }
+                    latest_cached_range = Some(cached_range);
+                }
+
+                match latest_cached_range {
+                    Some(latest_cached_range) => {
+                        if latest_cached_range.end.offset.saturating_add(1) < query_range.end.offset
+                        {
+                            ranges_to_query.push(latest_cached_range.end..query_range.end);
+                            latest_cached_range.end = query_range.end;
+                        }
+                    }
+                    None => {
+                        ranges_to_query.push(query_range.clone());
+                        self.sorted_ranges.push(query_range);
+                        self.sorted_ranges.sort_by(|range_a, range_b| {
+                            range_a.start.cmp(&range_b.start, buffer_snapshot)
+                        });
+                    }
+                }
+
+                ranges_to_query
+            }
+            InvalidationStrategy::RefreshRequested | InvalidationStrategy::BufferEdited => {
+                self.tasks.clear();
+                self.sorted_ranges.clear();
+                vec![query_range]
+            }
+        };
+
+        if !ranges_to_query.is_empty() {
+            self.tasks.push(spawn_task(ranges_to_query));
         }
     }
 }
@@ -168,7 +206,6 @@ impl InlayHintCache {
                     );
                     if new_splice.is_some() {
                         self.version += 1;
-                        self.update_tasks.clear();
                         self.allowed_hint_kinds = new_allowed_hint_kinds;
                     }
                     ControlFlow::Break(new_splice)
@@ -197,7 +234,7 @@ impl InlayHintCache {
 
     pub fn spawn_hint_refresh(
         &mut self,
-        mut excerpts_to_query: HashMap<ExcerptId, (ModelHandle<Buffer>, Global, Range<usize>)>,
+        excerpts_to_query: HashMap<ExcerptId, (ModelHandle<Buffer>, Global, Range<usize>)>,
         invalidate: InvalidationStrategy,
         cx: &mut ViewContext<Editor>,
     ) -> Option<InlaySplice> {
@@ -205,43 +242,23 @@ impl InlayHintCache {
             return None;
         }
 
-        let update_tasks = &mut self.update_tasks;
         let mut invalidated_hints = Vec::new();
         if invalidate.should_invalidate() {
-            let mut changed = false;
-            update_tasks.retain(|task_excerpt_id, _| {
-                let retain = excerpts_to_query.contains_key(task_excerpt_id);
-                changed |= !retain;
-                retain
-            });
+            self.update_tasks
+                .retain(|task_excerpt_id, _| excerpts_to_query.contains_key(task_excerpt_id));
             self.hints.retain(|cached_excerpt, cached_hints| {
                 let retain = excerpts_to_query.contains_key(cached_excerpt);
-                changed |= !retain;
                 if !retain {
                     invalidated_hints.extend(cached_hints.read().hints.iter().map(|&(id, _)| id));
                 }
                 retain
             });
-            if changed {
-                self.version += 1;
-            }
         }
         if excerpts_to_query.is_empty() && invalidated_hints.is_empty() {
             return None;
         }
 
-        let cache_version = self.version;
-        excerpts_to_query.retain(|visible_excerpt_id, _| {
-            match update_tasks.entry(*visible_excerpt_id) {
-                hash_map::Entry::Occupied(o) => match o.get().cache_version.cmp(&cache_version) {
-                    cmp::Ordering::Less => true,
-                    cmp::Ordering::Equal => invalidate.should_invalidate(),
-                    cmp::Ordering::Greater => false,
-                },
-                hash_map::Entry::Vacant(_) => true,
-            }
-        });
-
+        let cache_version = self.version + 1;
         cx.spawn(|editor, mut cx| async move {
             editor
                 .update(&mut cx, |editor, cx| {
@@ -368,6 +385,19 @@ impl InlayHintCache {
         self.update_tasks.clear();
         self.hints.clear();
     }
+
+    pub fn hints(&self) -> Vec<InlayHint> {
+        let mut hints = Vec::new();
+        for excerpt_hints in self.hints.values() {
+            let excerpt_hints = excerpt_hints.read();
+            hints.extend(excerpt_hints.hints.iter().map(|(_, hint)| hint).cloned());
+        }
+        hints
+    }
+
+    pub fn version(&self) -> usize {
+        self.version
+    }
 }
 
 fn spawn_new_update_tasks(
@@ -378,13 +408,14 @@ fn spawn_new_update_tasks(
     cx: &mut ViewContext<'_, '_, Editor>,
 ) {
     let visible_hints = Arc::new(editor.visible_inlay_hints(cx));
-    for (excerpt_id, (buffer_handle, new_task_buffer_version, excerpt_visible_range)) in
+    for (excerpt_id, (excerpt_buffer, new_task_buffer_version, excerpt_visible_range)) in
         excerpts_to_query
     {
         if excerpt_visible_range.is_empty() {
             continue;
         }
-        let buffer = buffer_handle.read(cx);
+        let buffer = excerpt_buffer.read(cx);
+        let buffer_id = buffer.remote_id();
         let buffer_snapshot = buffer.snapshot();
         if buffer_snapshot
             .version()
@@ -402,202 +433,123 @@ fn spawn_new_update_tasks(
             {
                 continue;
             }
-            if !new_task_buffer_version.changed_since(&cached_buffer_version)
-                && !matches!(invalidate, InvalidationStrategy::RefreshRequested)
-            {
-                continue;
-            }
         };
 
-        let buffer_id = buffer.remote_id();
-        let excerpt_visible_range_start = buffer.anchor_before(excerpt_visible_range.start);
-        let excerpt_visible_range_end = buffer.anchor_after(excerpt_visible_range.end);
-
-        let (multi_buffer_snapshot, full_excerpt_range) =
+        let (multi_buffer_snapshot, Some(query_range)) =
             editor.buffer.update(cx, |multi_buffer, cx| {
-                let multi_buffer_snapshot = multi_buffer.snapshot(cx);
                 (
-                    multi_buffer_snapshot,
-                    multi_buffer
-                        .excerpts_for_buffer(&buffer_handle, cx)
-                        .into_iter()
-                        .find(|(id, _)| id == &excerpt_id)
-                        .map(|(_, range)| range.context),
+                    multi_buffer.snapshot(cx),
+                    determine_query_range(
+                        multi_buffer,
+                        excerpt_id,
+                        &excerpt_buffer,
+                        excerpt_visible_range,
+                        cx,
+                    ),
                 )
-            });
+            }) else { return; };
+        let query = ExcerptQuery {
+            buffer_id,
+            excerpt_id,
+            cache_version: update_cache_version,
+            invalidate,
+        };
 
-        if let Some(full_excerpt_range) = full_excerpt_range {
-            let query = ExcerptQuery {
-                buffer_id,
-                excerpt_id,
-                dimensions: ExcerptDimensions {
-                    excerpt_range_start: full_excerpt_range.start,
-                    excerpt_range_end: full_excerpt_range.end,
-                    excerpt_visible_range_start,
-                    excerpt_visible_range_end,
-                },
-                cache_version: update_cache_version,
-                invalidate,
-            };
+        let new_update_task = |fetch_ranges| {
+            new_update_task(
+                query,
+                fetch_ranges,
+                multi_buffer_snapshot,
+                buffer_snapshot.clone(),
+                Arc::clone(&visible_hints),
+                cached_excerpt_hints,
+                cx,
+            )
+        };
 
-            let new_update_task = |is_refresh_after_regular_task| {
-                new_update_task(
-                    query,
-                    multi_buffer_snapshot,
-                    buffer_snapshot,
-                    Arc::clone(&visible_hints),
-                    cached_excerpt_hints,
-                    is_refresh_after_regular_task,
-                    cx,
-                )
-            };
-            match editor.inlay_hint_cache.update_tasks.entry(excerpt_id) {
-                hash_map::Entry::Occupied(mut o) => {
-                    let update_task = o.get_mut();
-                    match (update_task.invalidate, invalidate) {
-                        (_, InvalidationStrategy::None) => {}
-                        (
-                            InvalidationStrategy::BufferEdited,
-                            InvalidationStrategy::RefreshRequested,
-                        ) if !update_task.task.is_running_rx.is_closed() => {
-                            update_task.pending_refresh = Some(query);
-                        }
-                        _ => {
-                            o.insert(UpdateTask {
-                                invalidate,
-                                cache_version: query.cache_version,
-                                task: new_update_task(false),
-                                pending_refresh: None,
-                            });
-                        }
-                    }
-                }
-                hash_map::Entry::Vacant(v) => {
-                    v.insert(UpdateTask {
-                        invalidate,
-                        cache_version: query.cache_version,
-                        task: new_update_task(false),
-                        pending_refresh: None,
-                    });
-                }
+        match editor.inlay_hint_cache.update_tasks.entry(excerpt_id) {
+            hash_map::Entry::Occupied(mut o) => {
+                o.get_mut().update_cached_tasks(
+                    &buffer_snapshot,
+                    query_range,
+                    invalidate,
+                    new_update_task,
+                );
+            }
+            hash_map::Entry::Vacant(v) => {
+                v.insert(TasksForRanges::new(
+                    vec![query_range.clone()],
+                    new_update_task(vec![query_range]),
+                ));
             }
         }
     }
 }
 
+fn determine_query_range(
+    multi_buffer: &mut MultiBuffer,
+    excerpt_id: ExcerptId,
+    excerpt_buffer: &ModelHandle<Buffer>,
+    excerpt_visible_range: Range<usize>,
+    cx: &mut ModelContext<'_, MultiBuffer>,
+) -> Option<Range<language::Anchor>> {
+    let full_excerpt_range = multi_buffer
+        .excerpts_for_buffer(excerpt_buffer, cx)
+        .into_iter()
+        .find(|(id, _)| id == &excerpt_id)
+        .map(|(_, range)| range.context)?;
+
+    let buffer = excerpt_buffer.read(cx);
+    let excerpt_visible_len = excerpt_visible_range.end - excerpt_visible_range.start;
+    let start_offset = excerpt_visible_range
+        .start
+        .saturating_sub(excerpt_visible_len)
+        .max(full_excerpt_range.start.offset);
+    let start = buffer.anchor_before(buffer.clip_offset(start_offset, Bias::Left));
+    let end_offset = excerpt_visible_range
+        .end
+        .saturating_add(excerpt_visible_len)
+        .min(full_excerpt_range.end.offset)
+        .min(buffer.len());
+    let end = buffer.anchor_after(buffer.clip_offset(end_offset, Bias::Right));
+    if start.cmp(&end, buffer).is_eq() {
+        None
+    } else {
+        Some(start..end)
+    }
+}
+
 fn new_update_task(
     query: ExcerptQuery,
+    hint_fetch_ranges: Vec<Range<language::Anchor>>,
     multi_buffer_snapshot: MultiBufferSnapshot,
     buffer_snapshot: BufferSnapshot,
     visible_hints: Arc<Vec<Inlay>>,
     cached_excerpt_hints: Option<Arc<RwLock<CachedExcerptHints>>>,
-    is_refresh_after_regular_task: bool,
     cx: &mut ViewContext<'_, '_, Editor>,
-) -> RunningTask {
-    let hints_fetch_ranges = query.hints_fetch_ranges(&buffer_snapshot);
-    let (is_running_tx, is_running_rx) = smol::channel::bounded(1);
-    let _task = cx.spawn(|editor, mut cx| async move {
-        let _is_running_tx = is_running_tx;
-        let create_update_task = |range| {
-            fetch_and_update_hints(
-                editor.clone(),
-                multi_buffer_snapshot.clone(),
-                buffer_snapshot.clone(),
-                Arc::clone(&visible_hints),
-                cached_excerpt_hints.as_ref().map(Arc::clone),
-                query,
-                range,
-                cx.clone(),
-            )
-        };
-
-        if is_refresh_after_regular_task {
-            let visible_range_has_updates =
-                match create_update_task(hints_fetch_ranges.visible_range).await {
-                    Ok(updated) => updated,
-                    Err(e) => {
-                        error!("inlay hint visible range update task failed: {e:#}");
-                        return;
-                    }
-                };
-
-            if visible_range_has_updates {
-                let other_update_results = futures::future::join_all(
-                    hints_fetch_ranges
-                        .other_ranges
-                        .into_iter()
-                        .map(create_update_task),
+) -> Task<()> {
+    cx.spawn(|editor, cx| async move {
+        let task_update_results =
+            futures::future::join_all(hint_fetch_ranges.into_iter().map(|range| {
+                fetch_and_update_hints(
+                    editor.clone(),
+                    multi_buffer_snapshot.clone(),
+                    buffer_snapshot.clone(),
+                    Arc::clone(&visible_hints),
+                    cached_excerpt_hints.as_ref().map(Arc::clone),
+                    query,
+                    range,
+                    cx.clone(),
                 )
-                .await;
-
-                for result in other_update_results {
-                    if let Err(e) = result {
-                        error!("inlay hint update task failed: {e:#}");
-                    }
-                }
-            }
-        } else {
-            let task_update_results = futures::future::join_all(
-                std::iter::once(hints_fetch_ranges.visible_range)
-                    .chain(hints_fetch_ranges.other_ranges.into_iter())
-                    .map(create_update_task),
-            )
+            }))
             .await;
 
-            for result in task_update_results {
-                if let Err(e) = result {
-                    error!("inlay hint update task failed: {e:#}");
-                }
+        for result in task_update_results {
+            if let Err(e) = result {
+                error!("inlay hint update task failed: {e:#}");
             }
         }
-
-        editor
-            .update(&mut cx, |editor, cx| {
-                let pending_refresh_query = editor
-                    .inlay_hint_cache
-                    .update_tasks
-                    .get_mut(&query.excerpt_id)
-                    .and_then(|task| task.pending_refresh.take());
-
-                if let Some(pending_refresh_query) = pending_refresh_query {
-                    let refresh_multi_buffer = editor.buffer().read(cx);
-                    let refresh_multi_buffer_snapshot = refresh_multi_buffer.snapshot(cx);
-                    let refresh_visible_hints = Arc::new(editor.visible_inlay_hints(cx));
-                    let refresh_cached_excerpt_hints = editor
-                        .inlay_hint_cache
-                        .hints
-                        .get(&pending_refresh_query.excerpt_id)
-                        .map(Arc::clone);
-                    if let Some(buffer) =
-                        refresh_multi_buffer.buffer(pending_refresh_query.buffer_id)
-                    {
-                        editor.inlay_hint_cache.update_tasks.insert(
-                            pending_refresh_query.excerpt_id,
-                            UpdateTask {
-                                invalidate: InvalidationStrategy::RefreshRequested,
-                                cache_version: editor.inlay_hint_cache.version,
-                                task: new_update_task(
-                                    pending_refresh_query,
-                                    refresh_multi_buffer_snapshot,
-                                    buffer.read(cx).snapshot(),
-                                    refresh_visible_hints,
-                                    refresh_cached_excerpt_hints,
-                                    true,
-                                    cx,
-                                ),
-                                pending_refresh: None,
-                            },
-                        );
-                    }
-                }
-            })
-            .ok();
-    });
-
-    RunningTask {
-        _task,
-        is_running_rx,
-    }
+    })
 }
 
 async fn fetch_and_update_hints(
@@ -609,7 +561,7 @@ async fn fetch_and_update_hints(
     query: ExcerptQuery,
     fetch_range: Range<language::Anchor>,
     mut cx: gpui::AsyncAppContext,
-) -> anyhow::Result<bool> {
+) -> anyhow::Result<()> {
     let inlay_hints_fetch_task = editor
         .update(&mut cx, |editor, cx| {
             editor
@@ -625,11 +577,10 @@ async fn fetch_and_update_hints(
         })
         .ok()
         .flatten();
-    let mut update_happened = false;
-    let Some(inlay_hints_fetch_task) = inlay_hints_fetch_task else { return Ok(update_happened) };
-    let new_hints = inlay_hints_fetch_task
-        .await
-        .context("inlay hint fetch task")?;
+    let new_hints = match inlay_hints_fetch_task {
+        Some(task) => task.await.context("inlay hint fetch task")?,
+        None => return Ok(()),
+    };
     let background_task_buffer_snapshot = buffer_snapshot.clone();
     let backround_fetch_range = fetch_range.clone();
     let new_update = cx
@@ -645,106 +596,21 @@ async fn fetch_and_update_hints(
             )
         })
         .await;
-
-    editor
-        .update(&mut cx, |editor, cx| {
-            if let Some(new_update) = new_update {
-                update_happened = !new_update.add_to_cache.is_empty()
-                    || !new_update.remove_from_cache.is_empty()
-                    || !new_update.remove_from_visible.is_empty();
-
-                let cached_excerpt_hints = editor
-                    .inlay_hint_cache
-                    .hints
-                    .entry(new_update.excerpt_id)
-                    .or_insert_with(|| {
-                        Arc::new(RwLock::new(CachedExcerptHints {
-                            version: query.cache_version,
-                            buffer_version: buffer_snapshot.version().clone(),
-                            buffer_id: query.buffer_id,
-                            hints: Vec::new(),
-                        }))
-                    });
-                let mut cached_excerpt_hints = cached_excerpt_hints.write();
-                match query.cache_version.cmp(&cached_excerpt_hints.version) {
-                    cmp::Ordering::Less => return,
-                    cmp::Ordering::Greater | cmp::Ordering::Equal => {
-                        cached_excerpt_hints.version = query.cache_version;
-                    }
-                }
-                cached_excerpt_hints
-                    .hints
-                    .retain(|(hint_id, _)| !new_update.remove_from_cache.contains(hint_id));
-                cached_excerpt_hints.buffer_version = buffer_snapshot.version().clone();
-                editor.inlay_hint_cache.version += 1;
-
-                let mut splice = InlaySplice {
-                    to_remove: new_update.remove_from_visible,
-                    to_insert: Vec::new(),
-                };
-
-                for new_hint in new_update.add_to_cache {
-                    let new_hint_position = multi_buffer_snapshot
-                        .anchor_in_excerpt(query.excerpt_id, new_hint.position);
-                    let new_inlay_id = post_inc(&mut editor.next_inlay_id);
-                    if editor
-                        .inlay_hint_cache
-                        .allowed_hint_kinds
-                        .contains(&new_hint.kind)
-                    {
-                        splice.to_insert.push(Inlay::hint(
-                            new_inlay_id,
-                            new_hint_position,
-                            &new_hint,
-                        ));
-                    }
-
-                    cached_excerpt_hints
-                        .hints
-                        .push((InlayId::Hint(new_inlay_id), new_hint));
-                }
-
-                cached_excerpt_hints
-                    .hints
-                    .sort_by(|(_, hint_a), (_, hint_b)| {
-                        hint_a.position.cmp(&hint_b.position, &buffer_snapshot)
-                    });
-                drop(cached_excerpt_hints);
-
-                if query.invalidate.should_invalidate() {
-                    let mut outdated_excerpt_caches = HashSet::default();
-                    for (excerpt_id, excerpt_hints) in &editor.inlay_hint_cache().hints {
-                        let excerpt_hints = excerpt_hints.read();
-                        if excerpt_hints.buffer_id == query.buffer_id
-                            && excerpt_id != &query.excerpt_id
-                            && buffer_snapshot
-                                .version()
-                                .changed_since(&excerpt_hints.buffer_version)
-                        {
-                            outdated_excerpt_caches.insert(*excerpt_id);
-                            splice
-                                .to_remove
-                                .extend(excerpt_hints.hints.iter().map(|(id, _)| id));
-                        }
-                    }
-                    editor
-                        .inlay_hint_cache
-                        .hints
-                        .retain(|excerpt_id, _| !outdated_excerpt_caches.contains(excerpt_id));
-                }
-
-                let InlaySplice {
-                    to_remove,
-                    to_insert,
-                } = splice;
-                if !to_remove.is_empty() || !to_insert.is_empty() {
-                    editor.splice_inlay_hints(to_remove, to_insert, cx)
-                }
-            }
-        })
-        .ok();
-
-    Ok(update_happened)
+    if let Some(new_update) = new_update {
+        editor
+            .update(&mut cx, |editor, cx| {
+                apply_hint_update(
+                    editor,
+                    new_update,
+                    query,
+                    buffer_snapshot,
+                    multi_buffer_snapshot,
+                    cx,
+                );
+            })
+            .ok();
+    }
+    Ok(())
 }
 
 fn calculate_hint_updates(
@@ -793,19 +659,6 @@ fn calculate_hint_updates(
             visible_hints
                 .iter()
                 .filter(|hint| hint.position.excerpt_id == query.excerpt_id)
-                .filter(|hint| {
-                    contains_position(&fetch_range, hint.position.text_anchor, buffer_snapshot)
-                })
-                .filter(|hint| {
-                    fetch_range
-                        .start
-                        .cmp(&hint.position.text_anchor, buffer_snapshot)
-                        .is_le()
-                        && fetch_range
-                            .end
-                            .cmp(&hint.position.text_anchor, buffer_snapshot)
-                            .is_ge()
-                })
                 .map(|inlay_hint| inlay_hint.id)
                 .filter(|hint_id| !excerpt_hints_to_persist.contains_key(hint_id)),
         );
@@ -819,16 +672,6 @@ fn calculate_hint_updates(
                     .filter(|(cached_inlay_id, _)| {
                         !excerpt_hints_to_persist.contains_key(cached_inlay_id)
                     })
-                    .filter(|(_, cached_hint)| {
-                        fetch_range
-                            .start
-                            .cmp(&cached_hint.position, buffer_snapshot)
-                            .is_le()
-                            && fetch_range
-                                .end
-                                .cmp(&cached_hint.position, buffer_snapshot)
-                                .is_ge()
-                    })
                     .map(|(cached_inlay_id, _)| *cached_inlay_id),
             );
         }
@@ -855,6 +698,113 @@ fn contains_position(
         && range.end.cmp(&position, buffer_snapshot).is_ge()
 }
 
+fn apply_hint_update(
+    editor: &mut Editor,
+    new_update: ExcerptHintsUpdate,
+    query: ExcerptQuery,
+    buffer_snapshot: BufferSnapshot,
+    multi_buffer_snapshot: MultiBufferSnapshot,
+    cx: &mut ViewContext<'_, '_, Editor>,
+) {
+    let cached_excerpt_hints = editor
+        .inlay_hint_cache
+        .hints
+        .entry(new_update.excerpt_id)
+        .or_insert_with(|| {
+            Arc::new(RwLock::new(CachedExcerptHints {
+                version: query.cache_version,
+                buffer_version: buffer_snapshot.version().clone(),
+                buffer_id: query.buffer_id,
+                hints: Vec::new(),
+            }))
+        });
+    let mut cached_excerpt_hints = cached_excerpt_hints.write();
+    match query.cache_version.cmp(&cached_excerpt_hints.version) {
+        cmp::Ordering::Less => return,
+        cmp::Ordering::Greater | cmp::Ordering::Equal => {
+            cached_excerpt_hints.version = query.cache_version;
+        }
+    }
+
+    let mut cached_inlays_changed = !new_update.remove_from_cache.is_empty();
+    cached_excerpt_hints
+        .hints
+        .retain(|(hint_id, _)| !new_update.remove_from_cache.contains(hint_id));
+    let mut splice = InlaySplice {
+        to_remove: new_update.remove_from_visible,
+        to_insert: Vec::new(),
+    };
+    for new_hint in new_update.add_to_cache {
+        let cached_hints = &mut cached_excerpt_hints.hints;
+        let insert_position = match cached_hints
+            .binary_search_by(|probe| probe.1.position.cmp(&new_hint.position, &buffer_snapshot))
+        {
+            Ok(i) => {
+                if cached_hints[i].1.text() == new_hint.text() {
+                    None
+                } else {
+                    Some(i)
+                }
+            }
+            Err(i) => Some(i),
+        };
+
+        if let Some(insert_position) = insert_position {
+            let new_inlay_id = post_inc(&mut editor.next_inlay_id);
+            if editor
+                .inlay_hint_cache
+                .allowed_hint_kinds
+                .contains(&new_hint.kind)
+            {
+                let new_hint_position =
+                    multi_buffer_snapshot.anchor_in_excerpt(query.excerpt_id, new_hint.position);
+                splice
+                    .to_insert
+                    .push(Inlay::hint(new_inlay_id, new_hint_position, &new_hint));
+            }
+            cached_hints.insert(insert_position, (InlayId::Hint(new_inlay_id), new_hint));
+            cached_inlays_changed = true;
+        }
+    }
+    cached_excerpt_hints.buffer_version = buffer_snapshot.version().clone();
+    drop(cached_excerpt_hints);
+
+    if query.invalidate.should_invalidate() {
+        let mut outdated_excerpt_caches = HashSet::default();
+        for (excerpt_id, excerpt_hints) in &editor.inlay_hint_cache().hints {
+            let excerpt_hints = excerpt_hints.read();
+            if excerpt_hints.buffer_id == query.buffer_id
+                && excerpt_id != &query.excerpt_id
+                && buffer_snapshot
+                    .version()
+                    .changed_since(&excerpt_hints.buffer_version)
+            {
+                outdated_excerpt_caches.insert(*excerpt_id);
+                splice
+                    .to_remove
+                    .extend(excerpt_hints.hints.iter().map(|(id, _)| id));
+            }
+        }
+        cached_inlays_changed |= !outdated_excerpt_caches.is_empty();
+        editor
+            .inlay_hint_cache
+            .hints
+            .retain(|excerpt_id, _| !outdated_excerpt_caches.contains(excerpt_id));
+    }
+
+    let InlaySplice {
+        to_remove,
+        to_insert,
+    } = splice;
+    let displayed_inlays_changed = !to_remove.is_empty() || !to_insert.is_empty();
+    if cached_inlays_changed || displayed_inlays_changed {
+        editor.inlay_hint_cache.version += 1;
+    }
+    if displayed_inlays_changed {
+        editor.splice_inlay_hints(to_remove, to_insert, cx)
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
@@ -866,6 +816,7 @@ mod tests {
     };
     use futures::StreamExt;
     use gpui::{executor::Deterministic, TestAppContext, ViewHandle};
+    use itertools::Itertools;
     use language::{
         language_settings::AllLanguageSettingsContent, FakeLspAdapter, Language, LanguageConfig,
     };
@@ -873,7 +824,7 @@ mod tests {
     use parking_lot::Mutex;
     use project::{FakeFs, Project};
     use settings::SettingsStore;
-    use text::Point;
+    use text::{Point, ToPoint};
     use workspace::Workspace;
 
     use crate::editor_tests::update_test_language_settings;
@@ -1879,7 +1830,7 @@ mod tests {
 
                     task_lsp_request_ranges.lock().push(params.range);
                     let query_start = params.range.start;
-                    let i = Arc::clone(&task_lsp_request_count).fetch_add(1, Ordering::SeqCst) + 1;
+                    let i = Arc::clone(&task_lsp_request_count).fetch_add(1, Ordering::Release) + 1;
                     Ok(Some(vec![lsp::InlayHint {
                         position: query_start,
                         label: lsp::InlayHintLabel::String(i.to_string()),
@@ -1894,18 +1845,44 @@ mod tests {
             })
             .next()
             .await;
+        fn editor_visible_range(
+            editor: &ViewHandle<Editor>,
+            cx: &mut gpui::TestAppContext,
+        ) -> Range<Point> {
+            let ranges = editor.update(cx, |editor, cx| editor.excerpt_visible_offsets(None, cx));
+            assert_eq!(
+                ranges.len(),
+                1,
+                "Single buffer should produce a single excerpt with visible range"
+            );
+            let (_, (excerpt_buffer, _, excerpt_visible_range)) =
+                ranges.into_iter().next().unwrap();
+            excerpt_buffer.update(cx, |buffer, _| {
+                let snapshot = buffer.snapshot();
+                let start = buffer
+                    .anchor_before(excerpt_visible_range.start)
+                    .to_point(&snapshot);
+                let end = buffer
+                    .anchor_after(excerpt_visible_range.end)
+                    .to_point(&snapshot);
+                start..end
+            })
+        }
+
+        let initial_visible_range = editor_visible_range(&editor, cx);
+        let expected_initial_query_range_end =
+            lsp::Position::new(initial_visible_range.end.row * 2, 1);
         cx.foreground().run_until_parked();
         editor.update(cx, |editor, cx| {
-            let mut ranges = lsp_request_ranges.lock().drain(..).collect::<Vec<_>>();
-            ranges.sort_by_key(|range| range.start);
-            assert_eq!(ranges.len(), 2, "When scroll is at the edge of a big document, its visible part + the rest should be queried for hints");
-            assert_eq!(ranges[0].start, lsp::Position::new(0, 0), "Should query from the beginning of the document");
-            assert_eq!(ranges[0].end.line, ranges[1].start.line, "Both requests should be on the same line");
-            assert_eq!(ranges[0].end.character + 1, ranges[1].start.character, "Both request should be concequent");
-
-            assert_eq!(lsp_request_count.load(Ordering::SeqCst), 2,
-                "When scroll is at the edge of a big document, its visible part + the rest should be queried for hints");
-            let expected_layers = vec!["1".to_string(), "2".to_string()];
+            let ranges = lsp_request_ranges.lock().drain(..).collect::<Vec<_>>();
+            assert_eq!(ranges.len(), 1,
+                "When scroll is at the edge of a big document, double of its visible part range should be queried for hints in one single big request, but got: {ranges:?}");
+            let query_range = &ranges[0];
+            assert_eq!(query_range.start, lsp::Position::new(0, 0), "Should query initially from the beginning of the document");
+            assert_eq!(query_range.end, expected_initial_query_range_end, "Should query initially for double lines of the visible part of the document");
+
+            assert_eq!(lsp_request_count.load(Ordering::Acquire), 1);
+            let expected_layers = vec!["1".to_string()];
             assert_eq!(
                 expected_layers,
                 cached_hint_labels(editor),
@@ -1913,37 +1890,114 @@ mod tests {
             );
             assert_eq!(expected_layers, visible_hint_labels(editor, cx));
             assert_eq!(
-                editor.inlay_hint_cache().version, 2,
-                "Both LSP queries should've bumped the cache version"
+                editor.inlay_hint_cache().version, 1,
+                "LSP queries should've bumped the cache version"
             );
         });
 
         editor.update(cx, |editor, cx| {
             editor.scroll_screen(&ScrollAmount::Page(1.0), cx);
             editor.scroll_screen(&ScrollAmount::Page(1.0), cx);
-            editor.change_selections(None, cx, |s| s.select_ranges([600..600]));
-            editor.handle_input("++++more text++++", cx);
         });
 
+        let visible_range_after_scrolls = editor_visible_range(&editor, cx);
+        let visible_line_count =
+            editor.update(cx, |editor, _| editor.visible_line_count().unwrap());
         cx.foreground().run_until_parked();
+        let selection_in_cached_range = editor.update(cx, |editor, cx| {
+            let ranges = lsp_request_ranges
+                .lock()
+                .drain(..)
+                .sorted_by_key(|r| r.start)
+                .collect::<Vec<_>>();
+            assert_eq!(
+                ranges.len(),
+                2,
+                "Should query 2 ranges after both scrolls, but got: {ranges:?}"
+            );
+            let first_scroll = &ranges[0];
+            let second_scroll = &ranges[1];
+            assert_eq!(
+                first_scroll.end, second_scroll.start,
+                "Should query 2 adjacent ranges after the scrolls, but got: {ranges:?}"
+            );
+            assert_eq!(
+                first_scroll.start, expected_initial_query_range_end,
+                "First scroll should start the query right after the end of the original scroll",
+            );
+            assert_eq!(
+                second_scroll.end,
+                lsp::Position::new(
+                    visible_range_after_scrolls.end.row
+                        + visible_line_count.ceil() as u32,
+                    0
+                ),
+                "Second scroll should query one more screen down after the end of the visible range"
+            );
+
+            assert_eq!(
+                lsp_request_count.load(Ordering::Acquire),
+                3,
+                "Should query for hints after every scroll"
+            );
+            let expected_layers = vec!["1".to_string(), "2".to_string(), "3".to_string()];
+            assert_eq!(
+                expected_layers,
+                cached_hint_labels(editor),
+                "Should have hints from the new LSP response after the edit"
+            );
+            assert_eq!(expected_layers, visible_hint_labels(editor, cx));
+            assert_eq!(
+                editor.inlay_hint_cache().version,
+                3,
+                "Should update the cache for every LSP response with hints added"
+            );
+
+            let mut selection_in_cached_range = visible_range_after_scrolls.end;
+            selection_in_cached_range.row -= visible_line_count.ceil() as u32;
+            selection_in_cached_range
+        });
+
+        editor.update(cx, |editor, cx| {
+            editor.change_selections(Some(Autoscroll::center()), cx, |s| {
+                s.select_ranges([selection_in_cached_range..selection_in_cached_range])
+            });
+        });
+        cx.foreground().run_until_parked();
+        editor.update(cx, |_, _| {
+            let ranges = lsp_request_ranges
+                .lock()
+                .drain(..)
+                .sorted_by_key(|r| r.start)
+                .collect::<Vec<_>>();
+            assert!(ranges.is_empty(), "No new ranges or LSP queries should be made after returning to the selection with cached hints");
+            assert_eq!(lsp_request_count.load(Ordering::Acquire), 3);
+        });
+
         editor.update(cx, |editor, cx| {
-            let mut ranges = lsp_request_ranges.lock().drain(..).collect::<Vec<_>>();
-            ranges.sort_by_key(|range| range.start);
-            assert_eq!(ranges.len(), 3, "When scroll is at the middle of a big document, its visible part + 2 other inbisible parts should be queried for hints");
-            assert_eq!(ranges[0].start, lsp::Position::new(0, 0), "Should query from the beginning of the document");
-            assert_eq!(ranges[0].end.line + 1, ranges[1].start.line, "Neighbour requests got on different lines due to the line end");
-            assert_ne!(ranges[0].end.character, 0, "First query was in the end of the line, not in the beginning");
-            assert_eq!(ranges[1].start.character, 0, "Second query got pushed into a new line and starts from the beginning");
-            assert_eq!(ranges[1].end.line, ranges[2].start.line, "Neighbour requests should be on the same line");
-            assert_eq!(ranges[1].end.character + 1, ranges[2].start.character, "Neighbour request should be concequent");
-
-            assert_eq!(lsp_request_count.load(Ordering::SeqCst), 5,
-                "When scroll not at the edge of a big document, visible part + 2 other parts should be queried for hints");
-            let expected_layers = vec!["3".to_string(), "4".to_string(), "5".to_string()];
+            editor.handle_input("++++more text++++", cx);
+        });
+        cx.foreground().run_until_parked();
+        editor.update(cx, |editor, cx| {
+            let ranges = lsp_request_ranges.lock().drain(..).collect::<Vec<_>>();
+            assert_eq!(ranges.len(), 1,
+                "On edit, should scroll to selection and query a range around it. Instead, got query ranges {ranges:?}");
+            let query_range = &ranges[0];
+            assert!(query_range.start.line < selection_in_cached_range.row,
+                "Hints should be queried with the selected range after the query range start");
+            assert!(query_range.end.line > selection_in_cached_range.row,
+                "Hints should be queried with the selected range before the query range end");
+            assert!(query_range.start.line <= selection_in_cached_range.row - (visible_line_count * 3.0 / 2.0) as u32,
+                "Hints query range should contain one more screen before");
+            assert!(query_range.end.line >= selection_in_cached_range.row + (visible_line_count * 3.0 / 2.0) as u32,
+                "Hints query range should contain one more screen after");
+
+            assert_eq!(lsp_request_count.load(Ordering::Acquire), 4, "Should query for hints once after the edit");
+            let expected_layers = vec!["4".to_string()];
             assert_eq!(expected_layers, cached_hint_labels(editor),
-                "Should have hints from the new LSP response after edit");
+                "Should have hints from the new LSP response after the edit");
             assert_eq!(expected_layers, visible_hint_labels(editor, cx));
-            assert_eq!(editor.inlay_hint_cache().version, 5, "Should update the cache for every LSP response with hints added");
+            assert_eq!(editor.inlay_hint_cache().version, 4, "Should update the cache for every LSP response with hints added");
         });
     }
 

crates/editor/src/movement.rs 🔗

@@ -13,6 +13,13 @@ pub fn left(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint {
     map.clip_point(point, Bias::Left)
 }
 
+pub fn saturating_left(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint {
+    if point.column() > 0 {
+        *point.column_mut() -= 1;
+    }
+    map.clip_point(point, Bias::Left)
+}
+
 pub fn right(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint {
     let max_column = map.line_len(point.row());
     if point.column() < max_column {
@@ -24,6 +31,11 @@ pub fn right(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint {
     map.clip_point(point, Bias::Right)
 }
 
+pub fn saturating_right(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint {
+    *point.column_mut() += 1;
+    map.clip_point(point, Bias::Right)
+}
+
 pub fn up(
     map: &DisplaySnapshot,
     start: DisplayPoint,

crates/editor/src/multi_buffer.rs 🔗

@@ -1565,6 +1565,25 @@ impl MultiBuffer {
         cx.add_model(|cx| Self::singleton(buffer, cx))
     }
 
+    pub fn build_multi<const COUNT: usize>(
+        excerpts: [(&str, Vec<Range<Point>>); COUNT],
+        cx: &mut gpui::AppContext,
+    ) -> ModelHandle<Self> {
+        let multi = cx.add_model(|_| Self::new(0));
+        for (text, ranges) in excerpts {
+            let buffer = cx.add_model(|cx| Buffer::new(0, text, cx));
+            let excerpt_ranges = ranges.into_iter().map(|range| ExcerptRange {
+                context: range,
+                primary: None,
+            });
+            multi.update(cx, |multi, cx| {
+                multi.push_excerpts(buffer, excerpt_ranges, cx)
+            });
+        }
+
+        multi
+    }
+
     pub fn build_from_buffer(
         buffer: ModelHandle<Buffer>,
         cx: &mut gpui::AppContext,

crates/editor/src/scroll.rs 🔗

@@ -13,7 +13,7 @@ use gpui::{
 };
 use language::{Bias, Point};
 use util::ResultExt;
-use workspace::{item::Item, WorkspaceId};
+use workspace::WorkspaceId;
 
 use crate::{
     display_map::{DisplaySnapshot, ToDisplayPoint},
@@ -333,9 +333,7 @@ impl Editor {
             cx,
         );
 
-        if !self.is_singleton(cx) {
-            self.refresh_inlays(InlayRefreshReason::NewLinesShown, cx);
-        }
+        self.refresh_inlays(InlayRefreshReason::NewLinesShown, cx);
     }
 
     pub fn scroll_position(&self, cx: &mut ViewContext<Self>) -> Vector2F {

crates/feedback/src/deploy_feedback_button.rs 🔗

@@ -35,7 +35,7 @@ impl View for DeployFeedbackButton {
         let theme = theme::current(cx).clone();
         Stack::new()
             .with_child(
-                MouseEventHandler::<Self, Self>::new(0, cx, |state, _| {
+                MouseEventHandler::new::<Self, _>(0, cx, |state, _| {
                     let style = &theme
                         .workspace
                         .status_bar

crates/feedback/src/feedback_info_text.rs 🔗

@@ -41,7 +41,7 @@ impl View for FeedbackInfoText {
                 .aligned(),
             )
             .with_child(
-                MouseEventHandler::<OpenZedCommunityRepo, Self>::new(0, cx, |state, _| {
+                MouseEventHandler::new::<OpenZedCommunityRepo, _>(0, cx, |state, _| {
                     let contained_text = if state.hovered() {
                         &theme.feedback.link_text_hover
                     } else {

crates/feedback/src/submit_feedback_button.rs 🔗

@@ -52,7 +52,7 @@ impl View for SubmitFeedbackButton {
             .map_or(true, |i| i.read(cx).allow_submission);
 
         enum SubmitFeedbackButton {}
-        MouseEventHandler::<SubmitFeedbackButton, Self>::new(0, cx, |state, _| {
+        MouseEventHandler::new::<SubmitFeedbackButton, _>(0, cx, |state, _| {
             let text;
             let style = if allow_submission {
                 text = "Submit as Markdown";

crates/gpui/examples/components.rs 🔗

@@ -0,0 +1,237 @@
+use button_component::Button;
+
+use gpui::{
+    color::Color,
+    elements::{Component, ContainerStyle, Flex, Label, ParentElement},
+    fonts::{self, TextStyle},
+    platform::WindowOptions,
+    AnyElement, App, Element, Entity, View, ViewContext,
+};
+use log::LevelFilter;
+use pathfinder_geometry::vector::vec2f;
+use simplelog::SimpleLogger;
+use theme::Toggleable;
+use toggleable_button::ToggleableButton;
+
+// cargo run -p gpui --example components
+
+fn main() {
+    SimpleLogger::init(LevelFilter::Info, Default::default()).expect("could not initialize logger");
+
+    App::new(()).unwrap().run(|cx| {
+        cx.platform().activate(true);
+        cx.add_window(WindowOptions::with_bounds(vec2f(300., 200.)), |_| {
+            TestView {
+                count: 0,
+                is_doubling: false,
+            }
+        });
+    });
+}
+
+pub struct TestView {
+    count: usize,
+    is_doubling: bool,
+}
+
+impl TestView {
+    fn increase_count(&mut self) {
+        if self.is_doubling {
+            self.count *= 2;
+        } else {
+            self.count += 1;
+        }
+    }
+}
+
+impl Entity for TestView {
+    type Event = ();
+}
+
+type ButtonStyle = ContainerStyle;
+
+impl View for TestView {
+    fn ui_name() -> &'static str {
+        "TestView"
+    }
+
+    fn render(&mut self, cx: &mut ViewContext<'_, '_, Self>) -> AnyElement<Self> {
+        fonts::with_font_cache(cx.font_cache.to_owned(), || {
+            Flex::column()
+                .with_child(Label::new(
+                    format!("Count: {}", self.count),
+                    TextStyle::for_color(Color::red()),
+                ))
+                .with_child(
+                    Button::new(move |_, v: &mut Self, cx| {
+                        v.increase_count();
+                        cx.notify();
+                    })
+                    .with_text(
+                        "Hello from a counting BUTTON",
+                        TextStyle::for_color(Color::blue()),
+                    )
+                    .with_style(ButtonStyle::fill(Color::yellow()))
+                    .into_element(),
+                )
+                .with_child(
+                    ToggleableButton::new(self.is_doubling, move |_, v: &mut Self, cx| {
+                        v.is_doubling = !v.is_doubling;
+                        cx.notify();
+                    })
+                    .with_text("Double the count?", TextStyle::for_color(Color::black()))
+                    .with_style(Toggleable {
+                        inactive: ButtonStyle::fill(Color::red()),
+                        active: ButtonStyle::fill(Color::green()),
+                    })
+                    .into_element(),
+                )
+                .expanded()
+                .contained()
+                .with_background_color(Color::white())
+                .into_any()
+        })
+    }
+}
+
+mod theme {
+    pub struct Toggleable<T> {
+        pub inactive: T,
+        pub active: T,
+    }
+
+    impl<T> Toggleable<T> {
+        pub fn style_for(&self, active: bool) -> &T {
+            if active {
+                &self.active
+            } else {
+                &self.inactive
+            }
+        }
+    }
+}
+
+// Component creation:
+mod toggleable_button {
+    use gpui::{
+        elements::{Component, ContainerStyle, LabelStyle},
+        scene::MouseClick,
+        EventContext, View,
+    };
+
+    use crate::{button_component::Button, theme::Toggleable};
+
+    pub struct ToggleableButton<V: View> {
+        active: bool,
+        style: Option<Toggleable<ContainerStyle>>,
+        button: Button<V>,
+    }
+
+    impl<V: View> ToggleableButton<V> {
+        pub fn new<F>(active: bool, on_click: F) -> Self
+        where
+            F: Fn(MouseClick, &mut V, &mut EventContext<V>) + 'static,
+        {
+            Self {
+                active,
+                button: Button::new(on_click),
+                style: None,
+            }
+        }
+
+        pub fn with_text(self, text: &str, style: impl Into<LabelStyle>) -> ToggleableButton<V> {
+            ToggleableButton {
+                active: self.active,
+                style: self.style,
+                button: self.button.with_text(text, style),
+            }
+        }
+
+        pub fn with_style(self, style: Toggleable<ContainerStyle>) -> ToggleableButton<V> {
+            ToggleableButton {
+                active: self.active,
+                style: Some(style),
+                button: self.button,
+            }
+        }
+    }
+
+    impl<V: View> Component<V> for ToggleableButton<V> {
+        fn render(self, v: &mut V, cx: &mut gpui::ViewContext<V>) -> gpui::AnyElement<V> {
+            let button = if let Some(style) = self.style {
+                self.button.with_style(*style.style_for(self.active))
+            } else {
+                self.button
+            };
+            button.render(v, cx)
+        }
+    }
+}
+
+mod button_component {
+
+    use gpui::{
+        elements::{Component, ContainerStyle, Label, LabelStyle, MouseEventHandler},
+        platform::MouseButton,
+        scene::MouseClick,
+        AnyElement, Element, EventContext, TypeTag, View, ViewContext,
+    };
+
+    type ClickHandler<V> = Box<dyn Fn(MouseClick, &mut V, &mut EventContext<V>)>;
+
+    pub struct Button<V: View> {
+        click_handler: ClickHandler<V>,
+        tag: TypeTag,
+        contents: Option<AnyElement<V>>,
+        style: Option<ContainerStyle>,
+    }
+
+    impl<V: View> Button<V> {
+        pub fn new<F: Fn(MouseClick, &mut V, &mut EventContext<V>) + 'static>(handler: F) -> Self {
+            Self {
+                click_handler: Box::new(handler),
+                tag: TypeTag::new::<F>(),
+                style: None,
+                contents: None,
+            }
+        }
+
+        pub fn with_text(mut self, text: &str, style: impl Into<LabelStyle>) -> Self {
+            self.contents = Some(Label::new(text.to_string(), style).into_any());
+            self
+        }
+
+        pub fn _with_contents<E: Element<V>>(mut self, contents: E) -> Self {
+            self.contents = Some(contents.into_any());
+            self
+        }
+
+        pub fn with_style(mut self, style: ContainerStyle) -> Self {
+            self.style = Some(style);
+            self
+        }
+    }
+
+    impl<V: View> Component<V> for Button<V> {
+        fn render(self, _: &mut V, cx: &mut ViewContext<V>) -> AnyElement<V> {
+            let click_handler = self.click_handler;
+
+            let result = MouseEventHandler::new_dynamic(self.tag, 0, cx, |_, _| {
+                self.contents
+                    .unwrap_or_else(|| gpui::elements::Empty::new().into_any())
+            })
+            .on_click(MouseButton::Left, move |click, v, cx| {
+                click_handler(click, v, cx);
+            })
+            .contained();
+
+            let result = if let Some(style) = self.style {
+                result.with_style(style)
+            } else {
+                result
+            };
+
+            result.into_any()
+        }
+    }
+}

crates/gpui/src/app.rs 🔗

@@ -3280,7 +3280,11 @@ impl<'a, 'b, V: View> ViewContext<'a, 'b, V> {
     }
 
     pub fn mouse_state<Tag: 'static>(&self, region_id: usize) -> MouseState {
-        let region_id = MouseRegionId::new::<Tag>(self.view_id, region_id);
+        self.mouse_state_dynamic(TypeTag::new::<Tag>(), region_id)
+    }
+
+    pub fn mouse_state_dynamic(&self, tag: TypeTag, region_id: usize) -> MouseState {
+        let region_id = MouseRegionId::new(tag, self.view_id, region_id);
         MouseState {
             hovered: self.window.hovered_region_ids.contains(&region_id),
             clicked: if let Some((clicked_region_id, button)) = self.window.clicked_region {
@@ -3321,6 +3325,36 @@ impl<'a, 'b, V: View> ViewContext<'a, 'b, V> {
     }
 }
 
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
+pub struct TypeTag {
+    tag: TypeId,
+    #[cfg(debug_assertions)]
+    tag_type_name: &'static str,
+}
+
+impl TypeTag {
+    pub fn new<Tag: 'static>() -> Self {
+        Self {
+            tag: TypeId::of::<Tag>(),
+            #[cfg(debug_assertions)]
+            tag_type_name: std::any::type_name::<Tag>(),
+        }
+    }
+
+    pub fn dynamic(tag: TypeId, #[cfg(debug_assertions)] type_name: &'static str) -> Self {
+        Self {
+            tag,
+            #[cfg(debug_assertions)]
+            tag_type_name: type_name,
+        }
+    }
+
+    #[cfg(debug_assertions)]
+    pub(crate) fn type_name(&self) -> &'static str {
+        self.tag_type_name
+    }
+}
+
 impl<V> BorrowAppContext for ViewContext<'_, '_, V> {
     fn read_with<T, F: FnOnce(&AppContext) -> T>(&self, f: F) -> T {
         BorrowAppContext::read_with(&*self.window_context, f)
@@ -5171,7 +5205,7 @@ mod tests {
             fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
                 enum Handler {}
                 let mouse_down_count = self.mouse_down_count.clone();
-                MouseEventHandler::<Handler, _>::new(0, cx, |_, _| Empty::new())
+                MouseEventHandler::new::<Handler, _>(0, cx, |_, _| Empty::new())
                     .on_down(MouseButton::Left, move |_, _, _| {
                         mouse_down_count.fetch_add(1, SeqCst);
                     })

crates/gpui/src/elements.rs 🔗

@@ -1,6 +1,7 @@
 mod align;
 mod canvas;
 mod clipped;
+mod component;
 mod constrained_box;
 mod container;
 mod empty;
@@ -21,9 +22,9 @@ mod tooltip;
 mod uniform_list;
 
 pub use self::{
-    align::*, canvas::*, constrained_box::*, container::*, empty::*, flex::*, hook::*, image::*,
-    keystroke_label::*, label::*, list::*, mouse_event_handler::*, overlay::*, resizable::*,
-    stack::*, svg::*, text::*, tooltip::*, uniform_list::*,
+    align::*, canvas::*, component::*, constrained_box::*, container::*, empty::*, flex::*,
+    hook::*, image::*, keystroke_label::*, label::*, list::*, mouse_event_handler::*, overlay::*,
+    resizable::*, stack::*, svg::*, text::*, tooltip::*, uniform_list::*,
 };
 pub use crate::window::ChildView;
 
@@ -193,11 +194,11 @@ pub trait Element<V: View>: 'static {
         Resizable::new(self.into_any(), side, size, on_resize)
     }
 
-    fn mouse<Tag>(self, region_id: usize) -> MouseEventHandler<Tag, V>
+    fn mouse<Tag: 'static>(self, region_id: usize) -> MouseEventHandler<V>
     where
         Self: Sized,
     {
-        MouseEventHandler::for_child(self.into_any(), region_id)
+        MouseEventHandler::for_child::<Tag>(self.into_any(), region_id)
     }
 }
 

crates/gpui/src/elements/component.rs 🔗

@@ -0,0 +1,87 @@
+use std::marker::PhantomData;
+
+use pathfinder_geometry::{rect::RectF, vector::Vector2F};
+
+use crate::{
+    AnyElement, Element, LayoutContext, PaintContext, SceneBuilder, SizeConstraint, View,
+    ViewContext,
+};
+
+pub trait Component<V: View> {
+    fn render(self, v: &mut V, cx: &mut ViewContext<V>) -> AnyElement<V>;
+
+    fn into_element(self) -> ComponentAdapter<V, Self>
+    where
+        Self: Sized,
+    {
+        ComponentAdapter::new(self)
+    }
+}
+
+pub struct ComponentAdapter<V, E> {
+    component: Option<E>,
+    phantom: PhantomData<V>,
+}
+
+impl<E, V> ComponentAdapter<V, E> {
+    pub fn new(e: E) -> Self {
+        Self {
+            component: Some(e),
+            phantom: PhantomData,
+        }
+    }
+}
+
+impl<V: View, C: Component<V> + 'static> Element<V> for ComponentAdapter<V, C> {
+    type LayoutState = AnyElement<V>;
+
+    type PaintState = ();
+
+    fn layout(
+        &mut self,
+        constraint: SizeConstraint,
+        view: &mut V,
+        cx: &mut LayoutContext<V>,
+    ) -> (Vector2F, Self::LayoutState) {
+        let component = self.component.take().unwrap();
+        let mut element = component.render(view, cx.view_context());
+        let constraint = element.layout(constraint, view, cx);
+        (constraint, element)
+    }
+
+    fn paint(
+        &mut self,
+        scene: &mut SceneBuilder,
+        bounds: RectF,
+        visible_bounds: RectF,
+        layout: &mut Self::LayoutState,
+        view: &mut V,
+        cx: &mut PaintContext<V>,
+    ) -> Self::PaintState {
+        layout.paint(scene, bounds.origin(), visible_bounds, view, cx)
+    }
+
+    fn rect_for_text_range(
+        &self,
+        range_utf16: std::ops::Range<usize>,
+        _: RectF,
+        _: RectF,
+        element: &Self::LayoutState,
+        _: &Self::PaintState,
+        view: &V,
+        cx: &ViewContext<V>,
+    ) -> Option<RectF> {
+        element.rect_for_text_range(range_utf16, view, cx)
+    }
+
+    fn debug(
+        &self,
+        _: RectF,
+        element: &Self::LayoutState,
+        _: &Self::PaintState,
+        view: &V,
+        cx: &ViewContext<V>,
+    ) -> serde_json::Value {
+        element.debug(view, cx)
+    }
+}

crates/gpui/src/elements/container.rs 🔗

@@ -38,6 +38,15 @@ pub struct ContainerStyle {
     pub cursor: Option<CursorStyle>,
 }
 
+impl ContainerStyle {
+    pub fn fill(color: Color) -> Self {
+        Self {
+            background_color: Some(color),
+            ..Default::default()
+        }
+    }
+}
+
 pub struct Container<V: View> {
     child: AnyElement<V>,
     style: ContainerStyle,

crates/gpui/src/elements/mouse_event_handler.rs 🔗

@@ -11,12 +11,12 @@ use crate::{
         MouseHover, MouseMove, MouseMoveOut, MouseScrollWheel, MouseUp, MouseUpOut,
     },
     AnyElement, Element, EventContext, LayoutContext, MouseRegion, MouseState, PaintContext,
-    SceneBuilder, SizeConstraint, View, ViewContext,
+    SceneBuilder, SizeConstraint, TypeTag, View, ViewContext,
 };
 use serde_json::json;
-use std::{marker::PhantomData, ops::Range};
+use std::ops::Range;
 
-pub struct MouseEventHandler<Tag: 'static, V: View> {
+pub struct MouseEventHandler<V: View> {
     child: AnyElement<V>,
     region_id: usize,
     cursor_style: Option<CursorStyle>,
@@ -26,13 +26,13 @@ pub struct MouseEventHandler<Tag: 'static, V: View> {
     notify_on_click: bool,
     above: bool,
     padding: Padding,
-    _tag: PhantomData<Tag>,
+    tag: TypeTag,
 }
 
 /// Element which provides a render_child callback with a MouseState and paints a mouse
 /// region under (or above) it for easy mouse event handling.
-impl<Tag, V: View> MouseEventHandler<Tag, V> {
-    pub fn for_child(child: impl Element<V>, region_id: usize) -> Self {
+impl<V: View> MouseEventHandler<V> {
+    pub fn for_child<Tag: 'static>(child: impl Element<V>, region_id: usize) -> Self {
         Self {
             child: child.into_any(),
             region_id,
@@ -43,16 +43,19 @@ impl<Tag, V: View> MouseEventHandler<Tag, V> {
             hoverable: false,
             above: false,
             padding: Default::default(),
-            _tag: PhantomData,
+            tag: TypeTag::new::<Tag>(),
         }
     }
 
-    pub fn new<E, F>(region_id: usize, cx: &mut ViewContext<V>, render_child: F) -> Self
+    pub fn new<Tag: 'static, E>(
+        region_id: usize,
+        cx: &mut ViewContext<V>,
+        render_child: impl FnOnce(&mut MouseState, &mut ViewContext<V>) -> E,
+    ) -> Self
     where
         E: Element<V>,
-        F: FnOnce(&mut MouseState, &mut ViewContext<V>) -> E,
     {
-        let mut mouse_state = cx.mouse_state::<Tag>(region_id);
+        let mut mouse_state = cx.mouse_state_dynamic(TypeTag::new::<Tag>(), region_id);
         let child = render_child(&mut mouse_state, cx).into_any();
         let notify_on_hover = mouse_state.accessed_hovered();
         let notify_on_click = mouse_state.accessed_clicked();
@@ -66,19 +69,46 @@ impl<Tag, V: View> MouseEventHandler<Tag, V> {
             hoverable: true,
             above: false,
             padding: Default::default(),
-            _tag: PhantomData,
+            tag: TypeTag::new::<Tag>(),
+        }
+    }
+
+    pub fn new_dynamic(
+        tag: TypeTag,
+        region_id: usize,
+        cx: &mut ViewContext<V>,
+        render_child: impl FnOnce(&mut MouseState, &mut ViewContext<V>) -> AnyElement<V>,
+    ) -> Self {
+        let mut mouse_state = cx.mouse_state_dynamic(tag, region_id);
+        let child = render_child(&mut mouse_state, cx);
+        let notify_on_hover = mouse_state.accessed_hovered();
+        let notify_on_click = mouse_state.accessed_clicked();
+        Self {
+            child,
+            region_id,
+            cursor_style: None,
+            handlers: Default::default(),
+            notify_on_hover,
+            notify_on_click,
+            hoverable: true,
+            above: false,
+            padding: Default::default(),
+            tag,
         }
     }
 
     /// Modifies the MouseEventHandler to render the MouseRegion above the child element. Useful
     /// for drag and drop handling and similar events which should be captured before the child
     /// gets the opportunity
-    pub fn above<D, F>(region_id: usize, cx: &mut ViewContext<V>, render_child: F) -> Self
+    pub fn above<Tag: 'static, D>(
+        region_id: usize,
+        cx: &mut ViewContext<V>,
+        render_child: impl FnOnce(&mut MouseState, &mut ViewContext<V>) -> D,
+    ) -> Self
     where
         D: Element<V>,
-        F: FnOnce(&mut MouseState, &mut ViewContext<V>) -> D,
     {
-        let mut handler = Self::new(region_id, cx, render_child);
+        let mut handler = Self::new::<Tag, _>(region_id, cx, render_child);
         handler.above = true;
         handler
     }
@@ -223,7 +253,8 @@ impl<Tag, V: View> MouseEventHandler<Tag, V> {
             });
         }
         scene.push_mouse_region(
-            MouseRegion::from_handlers::<Tag>(
+            MouseRegion::from_handlers(
+                self.tag,
                 cx.view_id(),
                 self.region_id,
                 hit_bounds,
@@ -236,7 +267,7 @@ impl<Tag, V: View> MouseEventHandler<Tag, V> {
     }
 }
 
-impl<Tag, V: View> Element<V> for MouseEventHandler<Tag, V> {
+impl<V: View> Element<V> for MouseEventHandler<V> {
     type LayoutState = ();
     type PaintState = ();
 

crates/gpui/src/elements/tooltip.rs 🔗

@@ -95,7 +95,7 @@ impl<V: View> Tooltip<V> {
         } else {
             None
         };
-        let child = MouseEventHandler::<MouseEventHandlerState<Tag>, _>::new(id, cx, |_, _| child)
+        let child = MouseEventHandler::new::<MouseEventHandlerState<Tag>, _>(id, cx, |_, _| child)
             .on_hover(move |e, _, cx| {
                 let position = e.position;
                 if e.started {

crates/gpui/src/fonts.rs 🔗

@@ -72,6 +72,13 @@ pub struct TextStyle {
 }
 
 impl TextStyle {
+    pub fn for_color(color: Color) -> Self {
+        Self {
+            color,
+            ..Default::default()
+        }
+    }
+
     pub fn refine(self, refinement: TextStyleRefinement) -> TextStyle {
         TextStyle {
             color: refinement.color.unwrap_or(self.color),

crates/gpui/src/platform.rs 🔗

@@ -24,6 +24,7 @@ use crate::{
 use anyhow::{anyhow, bail, Result};
 use async_task::Runnable;
 pub use event::*;
+use pathfinder_geometry::vector::vec2f;
 use postage::oneshot;
 use schemars::JsonSchema;
 use serde::Deserialize;
@@ -180,6 +181,16 @@ pub struct WindowOptions<'a> {
     pub screen: Option<Rc<dyn Screen>>,
 }
 
+impl<'a> WindowOptions<'a> {
+    pub fn with_bounds(bounds: Vector2F) -> Self {
+        Self {
+            bounds: WindowBounds::Fixed(RectF::new(vec2f(0., 0.), bounds)),
+            center: true,
+            ..Default::default()
+        }
+    }
+}
+
 #[derive(Debug)]
 pub struct TitlebarOptions<'a> {
     pub title: Option<&'a str>,

crates/gpui/src/scene/mouse_region.rs 🔗

@@ -1,13 +1,10 @@
-use crate::{platform::MouseButton, window::WindowContext, EventContext, View, ViewContext};
+use crate::{
+    platform::MouseButton, window::WindowContext, EventContext, TypeTag, View, ViewContext,
+};
 use collections::HashMap;
 use pathfinder_geometry::rect::RectF;
 use smallvec::SmallVec;
-use std::{
-    any::{Any, TypeId},
-    fmt::Debug,
-    mem::Discriminant,
-    rc::Rc,
-};
+use std::{any::Any, fmt::Debug, mem::Discriminant, rc::Rc};
 
 use super::{
     mouse_event::{
@@ -33,14 +30,27 @@ impl MouseRegion {
     /// should pass a different (consistent) region_id. If you have one big region that covers your
     /// whole component, just pass the view_id again.
     pub fn new<Tag: 'static>(view_id: usize, region_id: usize, bounds: RectF) -> Self {
-        Self::from_handlers::<Tag>(view_id, region_id, bounds, Default::default())
+        Self::from_handlers(
+            TypeTag::new::<Tag>(),
+            view_id,
+            region_id,
+            bounds,
+            Default::default(),
+        )
     }
 
     pub fn handle_all<Tag: 'static>(view_id: usize, region_id: usize, bounds: RectF) -> Self {
-        Self::from_handlers::<Tag>(view_id, region_id, bounds, HandlerSet::capture_all())
+        Self::from_handlers(
+            TypeTag::new::<Tag>(),
+            view_id,
+            region_id,
+            bounds,
+            HandlerSet::capture_all(),
+        )
     }
 
-    pub fn from_handlers<Tag: 'static>(
+    pub fn from_handlers(
+        tag: TypeTag,
         view_id: usize,
         region_id: usize,
         bounds: RectF,
@@ -49,10 +59,8 @@ impl MouseRegion {
         Self {
             id: MouseRegionId {
                 view_id,
-                tag: TypeId::of::<Tag>(),
+                tag,
                 region_id,
-                #[cfg(debug_assertions)]
-                tag_type_name: std::any::type_name::<Tag>(),
             },
             bounds,
             handlers,
@@ -180,20 +188,16 @@ impl MouseRegion {
 #[derive(Copy, Clone, Eq, PartialEq, Hash, Debug, PartialOrd, Ord)]
 pub struct MouseRegionId {
     view_id: usize,
-    tag: TypeId,
+    tag: TypeTag,
     region_id: usize,
-    #[cfg(debug_assertions)]
-    tag_type_name: &'static str,
 }
 
 impl MouseRegionId {
-    pub(crate) fn new<Tag: 'static>(view_id: usize, region_id: usize) -> Self {
+    pub(crate) fn new(tag: TypeTag, view_id: usize, region_id: usize) -> Self {
         MouseRegionId {
             view_id,
             region_id,
-            tag: TypeId::of::<Tag>(),
-            #[cfg(debug_assertions)]
-            tag_type_name: std::any::type_name::<Tag>(),
+            tag,
         }
     }
 
@@ -203,7 +207,7 @@ impl MouseRegionId {
 
     #[cfg(debug_assertions)]
     pub fn tag_type_name(&self) -> &'static str {
-        self.tag_type_name
+        self.tag.type_name()
     }
 }
 

crates/gpui/src/views/select.rs 🔗

@@ -106,7 +106,7 @@ impl View for Select {
             Default::default()
         };
         let mut result = Flex::column().with_child(
-            MouseEventHandler::<Header, _>::new(self.handle.id(), cx, |mouse_state, cx| {
+            MouseEventHandler::new::<Header, _>(self.handle.id(), cx, |mouse_state, cx| {
                 (self.render_item)(
                     self.selected_item_ix,
                     ItemType::Header,
@@ -130,7 +130,7 @@ impl View for Select {
                         let selected_item_ix = this.selected_item_ix;
                         range.end = range.end.min(this.item_count);
                         items.extend(range.map(|ix| {
-                            MouseEventHandler::<Item, _>::new(ix, cx, |mouse_state, cx| {
+                            MouseEventHandler::new::<Item, _>(ix, cx, |mouse_state, cx| {
                                 (this.render_item)(
                                     ix,
                                     if ix == selected_item_ix {

crates/gpui_macros/Cargo.toml 🔗

@@ -13,6 +13,3 @@ doctest = false
 syn = "1.0"
 quote = "1.0"
 proc-macro2 = "1.0"
-
-[dev-dependencies]
-gpui = { path = "../gpui" }

crates/language_selector/src/active_buffer_language.rs 🔗

@@ -53,7 +53,7 @@ impl View for ActiveBufferLanguage {
                 "Unknown".to_string()
             };
 
-            MouseEventHandler::<Self, Self>::new(0, cx, |state, cx| {
+            MouseEventHandler::new::<Self, _>(0, cx, |state, cx| {
                 let theme = &theme::current(cx).workspace.status_bar;
                 let style = theme.active_language.style_for(state);
                 Label::new(active_language_text, style.text.clone())

crates/language_tools/src/lsp_log.rs 🔗

@@ -573,7 +573,7 @@ impl View for LspLogToolbarItemView {
             .with_children(if self.menu_open {
                 Some(
                     Overlay::new(
-                        MouseEventHandler::<Menu, _>::new(0, cx, move |_, cx| {
+                        MouseEventHandler::new::<Menu, _>(0, cx, move |_, cx| {
                             Flex::column()
                                 .with_children(menu_rows.into_iter().map(|row| {
                                     Self::render_language_server_menu_item(
@@ -672,7 +672,7 @@ impl LspLogToolbarItemView {
         cx: &mut ViewContext<Self>,
     ) -> impl Element<Self> {
         enum ToggleMenu {}
-        MouseEventHandler::<ToggleMenu, Self>::new(0, cx, move |state, cx| {
+        MouseEventHandler::new::<ToggleMenu, _>(0, cx, move |state, cx| {
             let label: Cow<str> = current_server
                 .and_then(|row| {
                     let worktree = row.worktree.read(cx);
@@ -728,7 +728,7 @@ impl LspLogToolbarItemView {
                 .with_height(theme.toolbar_dropdown_menu.row_height)
             })
             .with_child(
-                MouseEventHandler::<ActivateLog, _>::new(id.0, cx, move |state, _| {
+                MouseEventHandler::new::<ActivateLog, _>(id.0, cx, move |state, _| {
                     let style = theme
                         .toolbar_dropdown_menu
                         .item
@@ -746,7 +746,7 @@ impl LspLogToolbarItemView {
                 }),
             )
             .with_child(
-                MouseEventHandler::<ActivateRpcTrace, _>::new(id.0, cx, move |state, cx| {
+                MouseEventHandler::new::<ActivateRpcTrace, _>(id.0, cx, move |state, cx| {
                     let style = theme
                         .toolbar_dropdown_menu
                         .item

crates/language_tools/src/syntax_tree_view.rs 🔗

@@ -389,7 +389,7 @@ impl View for SyntaxTreeView {
         {
             let layer = layer.clone();
             let theme = editor_theme.clone();
-            return MouseEventHandler::<Self, Self>::new(0, cx, move |state, cx| {
+            return MouseEventHandler::new::<Self, _>(0, cx, move |state, cx| {
                 let list_hovered = state.hovered();
                 UniformList::new(
                     self.list_state.clone(),
@@ -505,7 +505,7 @@ impl SyntaxTreeToolbarItemView {
                 .with_child(Self::render_header(&theme, &active_layer, cx))
                 .with_children(self.menu_open.then(|| {
                     Overlay::new(
-                        MouseEventHandler::<Menu, _>::new(0, cx, move |_, cx| {
+                        MouseEventHandler::new::<Menu, _>(0, cx, move |_, cx| {
                             Flex::column()
                                 .with_children(active_buffer.syntax_layers().enumerate().map(
                                     |(ix, layer)| {
@@ -564,7 +564,7 @@ impl SyntaxTreeToolbarItemView {
         cx: &mut ViewContext<Self>,
     ) -> impl Element<Self> {
         enum ToggleMenu {}
-        MouseEventHandler::<ToggleMenu, Self>::new(0, cx, move |state, _| {
+        MouseEventHandler::new::<ToggleMenu, _>(0, cx, move |state, _| {
             let style = theme.toolbar_dropdown_menu.header.style_for(state);
             Flex::row()
                 .with_child(
@@ -596,7 +596,7 @@ impl SyntaxTreeToolbarItemView {
         cx: &mut ViewContext<Self>,
     ) -> impl Element<Self> {
         enum ActivateLayer {}
-        MouseEventHandler::<ActivateLayer, _>::new(layer_ix, cx, move |state, _| {
+        MouseEventHandler::new::<ActivateLayer, _>(layer_ix, cx, move |state, _| {
             let is_selected = layer.node() == active_layer.node();
             let style = theme
                 .toolbar_dropdown_menu

crates/lsp/src/lsp.rs 🔗

@@ -434,7 +434,9 @@ impl LanguageServer {
                         ..Default::default()
                     }),
                     inlay_hint: Some(InlayHintClientCapabilities {
-                        resolve_support: None,
+                        resolve_support: Some(InlayHintResolveClientCapabilities {
+                            properties: vec!["textEdits".to_string(), "tooltip".to_string()],
+                        }),
                         dynamic_registration: Some(false),
                     }),
                     ..Default::default()

crates/picker/src/picker.rs 🔗

@@ -112,7 +112,7 @@ impl<D: PickerDelegate> View for Picker<D> {
                             let selected_ix = this.delegate.selected_index();
                             range.end = cmp::min(range.end, this.delegate.match_count());
                             items.extend(range.map(move |ix| {
-                                MouseEventHandler::<D, _>::new(ix, cx, |state, cx| {
+                                MouseEventHandler::new::<D, _>(ix, cx, |state, cx| {
                                     this.delegate.render_match(ix, state, ix == selected_ix, cx)
                                 })
                                 // Capture mouse events

crates/project/src/lsp_command.rs 🔗

@@ -1954,7 +1954,7 @@ impl LspCommand for InlayHints {
         _: &mut Project,
         _: PeerId,
         buffer_version: &clock::Global,
-        cx: &mut AppContext,
+        _: &mut AppContext,
     ) -> proto::InlayHintsResponse {
         proto::InlayHintsResponse {
             hints: response
@@ -1963,51 +1963,17 @@ impl LspCommand for InlayHints {
                     position: Some(language::proto::serialize_anchor(&response_hint.position)),
                     padding_left: response_hint.padding_left,
                     padding_right: response_hint.padding_right,
-                    label: Some(proto::InlayHintLabel {
-                        label: Some(match response_hint.label {
-                            InlayHintLabel::String(s) => proto::inlay_hint_label::Label::Value(s),
-                            InlayHintLabel::LabelParts(label_parts) => {
-                                proto::inlay_hint_label::Label::LabelParts(proto::InlayHintLabelParts {
-                                    parts: label_parts.into_iter().map(|label_part| proto::InlayHintLabelPart {
-                                        value: label_part.value,
-                                        tooltip: label_part.tooltip.map(|tooltip| {
-                                            let proto_tooltip = match tooltip {
-                                                InlayHintLabelPartTooltip::String(s) => proto::inlay_hint_label_part_tooltip::Content::Value(s),
-                                                InlayHintLabelPartTooltip::MarkupContent(markup_content) => proto::inlay_hint_label_part_tooltip::Content::MarkupContent(proto::MarkupContent {
-                                                    kind: markup_content.kind,
-                                                    value: markup_content.value,
-                                                }),
-                                            };
-                                            proto::InlayHintLabelPartTooltip {content: Some(proto_tooltip)}
-                                        }),
-                                        location: label_part.location.map(|location| proto::Location {
-                                            start: Some(serialize_anchor(&location.range.start)),
-                                            end: Some(serialize_anchor(&location.range.end)),
-                                            buffer_id: location.buffer.read(cx).remote_id(),
-                                        }),
-                                    }).collect()
-                                })
-                            }
-                        }),
-                    }),
                     kind: response_hint.kind.map(|kind| kind.name().to_string()),
-                    tooltip: response_hint.tooltip.map(|response_tooltip| {
-                        let proto_tooltip = match response_tooltip {
-                            InlayHintTooltip::String(s) => {
-                                proto::inlay_hint_tooltip::Content::Value(s)
-                            }
-                            InlayHintTooltip::MarkupContent(markup_content) => {
-                                proto::inlay_hint_tooltip::Content::MarkupContent(
-                                    proto::MarkupContent {
-                                        kind: markup_content.kind,
-                                        value: markup_content.value,
-                                    },
-                                )
-                            }
-                        };
-                        proto::InlayHintTooltip {
-                            content: Some(proto_tooltip),
-                        }
+                    // Do not pass extra data such as tooltips to clients: host can put tooltip data from the cache during resolution.
+                    tooltip: None,
+                    // Similarly, do not pass label parts to clients: host can return a detailed list during resolution.
+                    label: Some(proto::InlayHintLabel {
+                        label: Some(proto::inlay_hint_label::Label::Value(
+                            match response_hint.label {
+                                InlayHintLabel::String(s) => s,
+                                InlayHintLabel::LabelParts(_) => response_hint.text(),
+                            },
+                        )),
                     }),
                 })
                 .collect(),

crates/project_panel/src/project_panel.rs 🔗

@@ -1407,7 +1407,7 @@ impl ProjectPanel {
 
         let show_editor = details.is_editing && !details.is_processing;
 
-        MouseEventHandler::<Self, _>::new(entry_id.to_usize(), cx, |state, cx| {
+        MouseEventHandler::new::<Self, _>(entry_id.to_usize(), cx, |state, cx| {
             let mut style = entry_style
                 .in_state(details.is_selected)
                 .style_for(state)
@@ -1519,7 +1519,7 @@ impl View for ProjectPanel {
         if has_worktree {
             Stack::new()
                 .with_child(
-                    MouseEventHandler::<ProjectPanel, _>::new(0, cx, |_, cx| {
+                    MouseEventHandler::new::<ProjectPanel, _>(0, cx, |_, cx| {
                         UniformList::new(
                             self.list.clone(),
                             self.visible_entries
@@ -1563,7 +1563,7 @@ impl View for ProjectPanel {
         } else {
             Flex::column()
                 .with_child(
-                    MouseEventHandler::<Self, _>::new(2, cx, {
+                    MouseEventHandler::new::<Self, _>(2, cx, {
                         let button_style = theme.open_project_button.clone();
                         let context_menu_item_style = theme::current(cx).context_menu.item.clone();
                         move |state, cx| {

crates/search/src/buffer_search.rs 🔗

@@ -494,7 +494,7 @@ impl BufferSearchBar {
             CursorStyle::default()
         };
         enum ActionButton {}
-        MouseEventHandler::<ActionButton, _>::new(action_type_id, cx, |state, cx| {
+        MouseEventHandler::new::<ActionButton, _>(action_type_id, cx, |state, cx| {
             let theme = theme::current(cx);
             let style = theme
                 .search
@@ -519,6 +519,7 @@ impl BufferSearchBar {
         )
         .into_any()
     }
+
     pub fn activate_search_mode(&mut self, mode: SearchMode, cx: &mut ViewContext<Self>) {
         assert_ne!(
             mode,

crates/search/src/project_search.rs 🔗

@@ -406,7 +406,7 @@ impl View for ProjectSearchView {
                 editor.set_placeholder_text(new_placeholder_text, cx);
             });
 
-            MouseEventHandler::<Status, _>::new(0, cx, |_, _| {
+            MouseEventHandler::new::<Status, _>(0, cx, |_, _| {
                 Flex::column()
                     .with_child(Flex::column().contained().flex(1., true))
                     .with_child(

crates/search/src/search_bar.rs 🔗

@@ -23,7 +23,7 @@ pub(super) fn render_close_button<V: View>(
     let tooltip_style = theme::current(cx).tooltip.clone();
 
     enum CloseButton {}
-    MouseEventHandler::<CloseButton, _>::new(0, cx, |state, _| {
+    MouseEventHandler::new::<CloseButton, _>(0, cx, |state, _| {
         let style = theme.dismiss_button.style_for(state);
         Svg::new("icons/x_mark_8.svg")
             .with_color(style.color)
@@ -68,7 +68,7 @@ pub(super) fn render_nav_button<V: View>(
         CursorStyle::default()
     };
     enum NavButton {}
-    MouseEventHandler::<NavButton, _>::new(direction as usize, cx, |state, cx| {
+    MouseEventHandler::new::<NavButton, _>(direction as usize, cx, |state, cx| {
         let theme = theme::current(cx);
         let style = theme
             .search
@@ -117,7 +117,7 @@ pub(crate) fn render_search_mode_button<V: View>(
 ) -> AnyElement<V> {
     let tooltip_style = theme::current(cx).tooltip.clone();
     enum SearchModeButton {}
-    MouseEventHandler::<SearchModeButton, _>::new(mode.region_id(), cx, |state, cx| {
+    MouseEventHandler::new::<SearchModeButton, _>(mode.region_id(), cx, |state, cx| {
         let theme = theme::current(cx);
         let mut style = theme
             .search
@@ -177,7 +177,7 @@ pub(crate) fn render_option_button_icon<V: View>(
     cx: &mut ViewContext<V>,
 ) -> AnyElement<V> {
     let tooltip_style = theme::current(cx).tooltip.clone();
-    MouseEventHandler::<V, _>::new(id, cx, |state, cx| {
+    MouseEventHandler::new::<V, _>(id, cx, |state, cx| {
         let theme = theme::current(cx);
         let style = theme
             .search

crates/terminal/src/terminal.rs 🔗

@@ -987,6 +987,14 @@ impl Terminal {
         }
     }
 
+    pub fn select_all(&mut self) {
+        let term = self.term.lock();
+        let start = Point::new(term.topmost_line(), Column(0));
+        let end = Point::new(term.bottommost_line(), term.last_column());
+        drop(term);
+        self.set_selection(Some((make_selection(&(start..=end)), end)));
+    }
+
     fn set_selection(&mut self, selection: Option<(Selection, Point)>) {
         self.events
             .push_back(InternalEvent::SetSelection(selection));

crates/terminal_view/src/terminal_view.rs 🔗

@@ -80,6 +80,7 @@ pub fn init(cx: &mut AppContext) {
     cx.add_action(TerminalView::paste);
     cx.add_action(TerminalView::clear);
     cx.add_action(TerminalView::show_character_palette);
+    cx.add_action(TerminalView::select_all)
 }
 
 ///A terminal view, maintains the PTY's file handles and communicates with the terminal
@@ -312,6 +313,11 @@ impl TerminalView {
         }
     }
 
+    fn select_all(&mut self, _: &editor::SelectAll, cx: &mut ViewContext<Self>) {
+        self.terminal.update(cx, |term, _| term.select_all());
+        cx.notify();
+    }
+
     fn clear(&mut self, _: &Clear, cx: &mut ViewContext<Self>) {
         self.terminal.update(cx, |term, _| term.clear());
         cx.notify();

crates/theme/src/ui.rs 🔗

@@ -34,7 +34,7 @@ pub fn checkbox<Tag, V, F>(
     id: usize,
     cx: &mut ViewContext<V>,
     change: F,
-) -> MouseEventHandler<Tag, V>
+) -> MouseEventHandler<V>
 where
     Tag: 'static,
     V: View,
@@ -43,7 +43,7 @@ where
     let label = Label::new(label, style.label.text.clone())
         .contained()
         .with_style(style.label.container);
-    checkbox_with_label(label, style, checked, id, cx, change)
+    checkbox_with_label::<Tag, _, _, _>(label, style, checked, id, cx, change)
 }
 
 pub fn checkbox_with_label<Tag, D, V, F>(
@@ -53,14 +53,14 @@ pub fn checkbox_with_label<Tag, D, V, F>(
     id: usize,
     cx: &mut ViewContext<V>,
     change: F,
-) -> MouseEventHandler<Tag, V>
+) -> MouseEventHandler<V>
 where
     Tag: 'static,
     D: Element<V>,
     V: View,
     F: 'static + Fn(&mut V, bool, &mut EventContext<V>),
 {
-    MouseEventHandler::new(id, cx, |state, _| {
+    MouseEventHandler::new::<Tag, _>(id, cx, |state, _| {
         let indicator = if checked {
             svg(&style.icon)
         } else {
@@ -143,14 +143,14 @@ pub fn cta_button<Tag, L, V, F>(
     style: &ButtonStyle,
     cx: &mut ViewContext<V>,
     f: F,
-) -> MouseEventHandler<Tag, V>
+) -> MouseEventHandler<V>
 where
     Tag: 'static,
     L: Into<Cow<'static, str>>,
     V: View,
     F: Fn(MouseClick, &mut V, &mut EventContext<V>) + 'static,
 {
-    MouseEventHandler::<Tag, V>::new(0, cx, |state, _| {
+    MouseEventHandler::new::<Tag, _>(0, cx, |state, _| {
         let style = style.style_for(state);
         Label::new(label, style.text.to_owned())
             .aligned()
@@ -205,7 +205,7 @@ where
                 ))
                 .with_child(
                     // FIXME: Get a better tag type
-                    MouseEventHandler::<Tag, V>::new(999999, cx, |state, _cx| {
+                    MouseEventHandler::new::<Tag, _>(999999, cx, |state, _cx| {
                         let style = style.close_icon.style_for(state);
                         icon(style)
                     })

crates/vcs_menu/src/lib.rs 🔗

@@ -295,7 +295,7 @@ impl PickerDelegate for BranchListDelegate {
             let style = theme.picker.footer.clone();
             enum BranchCreateButton {}
             Some(
-                Flex::row().with_child(MouseEventHandler::<BranchCreateButton, _>::new(0, cx, |state, _| {
+                Flex::row().with_child(MouseEventHandler::new::<BranchCreateButton, _>(0, cx, |state, _| {
                     let style = style.style_for(state);
                     Label::new("Create branch", style.label.clone())
                         .contained()

crates/vim/src/mode_indicator.rs 🔗

@@ -87,7 +87,7 @@ impl View for ModeIndicator {
             Mode::Normal => "-- NORMAL --",
             Mode::Insert => "-- INSERT --",
             Mode::Visual { line: false } => "-- VISUAL --",
-            Mode::Visual { line: true } => "VISUAL LINE ",
+            Mode::Visual { line: true } => "VISUAL  LINE",
         };
         Label::new(text, theme.vim_mode_indicator.text.clone())
             .contained()

crates/vim/src/motion.rs 🔗

@@ -383,8 +383,7 @@ impl Motion {
 
 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::Left);
+        point = movement::saturating_left(map, point);
         if point.column() == 0 {
             break;
         }
@@ -425,9 +424,7 @@ fn up(
 
 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);
+        let new_point = movement::saturating_right(map, point);
         if point == new_point {
             break;
         }

crates/vim/src/normal.rs 🔗

@@ -3,7 +3,7 @@ mod change;
 mod delete;
 mod scroll;
 mod search;
-mod substitute;
+pub mod substitute;
 mod yank;
 
 use std::{borrow::Cow, sync::Arc};

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

@@ -1,34 +1,45 @@
 use gpui::WindowContext;
 use language::Point;
 
-use crate::{motion::Motion, Mode, Vim};
+use crate::{motion::Motion, utils::copy_selections_content, Mode, Vim};
 
 pub fn substitute(vim: &mut Vim, count: Option<usize>, cx: &mut WindowContext) {
+    let line_mode = vim.state.mode == Mode::Visual { line: true };
+    vim.switch_mode(Mode::Insert, true, cx);
     vim.update_active_editor(cx, |editor, cx| {
-        editor.set_clip_at_line_ends(false, cx);
         editor.transact(cx, |editor, cx| {
             editor.change_selections(None, cx, |s| {
                 s.move_with(|map, selection| {
                     if selection.start == selection.end {
                         Motion::Right.expand_selection(map, selection, count, true);
                     }
+                    if line_mode {
+                        Motion::CurrentLine.expand_selection(map, selection, None, false);
+                        if let Some((point, _)) = Motion::FirstNonWhitespace.move_point(
+                            map,
+                            selection.start,
+                            selection.goal,
+                            None,
+                        ) {
+                            selection.start = point;
+                        }
+                    }
                 })
             });
-            let selections = editor.selections.all::<Point>(cx);
-            for selection in selections.into_iter().rev() {
-                editor.buffer().update(cx, |buffer, cx| {
-                    buffer.edit([(selection.start..selection.end, "")], None, cx)
-                })
-            }
+            copy_selections_content(editor, line_mode, cx);
+            let selections = editor.selections.all::<Point>(cx).into_iter();
+            let edits = selections.map(|selection| (selection.start..selection.end, ""));
+            editor.edit(edits, cx);
         });
-        editor.set_clip_at_line_ends(true, cx);
     });
-    vim.switch_mode(Mode::Insert, true, cx)
 }
 
 #[cfg(test)]
 mod test {
-    use crate::{state::Mode, test::VimTestContext};
+    use crate::{
+        state::Mode,
+        test::{NeovimBackedTestContext, VimTestContext},
+    };
     use indoc::indoc;
 
     #[gpui::test]
@@ -69,5 +80,86 @@ mod test {
         // should transactionally undo selection changes
         cx.simulate_keystrokes(["escape", "u"]);
         cx.assert_editor_state("ˇcàfé\n");
+
+        // it handles visual line mode
+        cx.set_state(
+            indoc! {"
+            alpha
+              beˇta
+            gamma"},
+            Mode::Normal,
+        );
+        cx.simulate_keystrokes(["shift-v", "s"]);
+        cx.assert_editor_state(indoc! {"
+            alpha
+              ˇ
+            gamma"});
+    }
+
+    #[gpui::test]
+    async fn test_visual_change(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        cx.set_shared_state("The quick ˇbrown").await;
+        cx.simulate_shared_keystrokes(["v", "w", "c"]).await;
+        cx.assert_shared_state("The quick ˇ").await;
+
+        cx.set_shared_state(indoc! {"
+            The ˇquick brown
+            fox jumps over
+            the lazy dog"})
+            .await;
+        cx.simulate_shared_keystrokes(["v", "w", "j", "c"]).await;
+        cx.assert_shared_state(indoc! {"
+            The ˇver
+            the lazy dog"})
+            .await;
+
+        let cases = cx.each_marked_position(indoc! {"
+            The ˇquick brown
+            fox jumps ˇover
+            the ˇlazy dog"});
+        for initial_state in cases {
+            cx.assert_neovim_compatible(&initial_state, ["v", "w", "j", "c"])
+                .await;
+            cx.assert_neovim_compatible(&initial_state, ["v", "w", "k", "c"])
+                .await;
+        }
+    }
+
+    #[gpui::test]
+    async fn test_visual_line_change(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx)
+            .await
+            .binding(["shift-v", "c"]);
+        cx.assert(indoc! {"
+            The quˇick brown
+            fox jumps over
+            the lazy dog"})
+            .await;
+        // Test pasting code copied on change
+        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 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"})
+            .await;
+        // Test pasting code copied on delete
+        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 laˇzy dog"})
+            .await;
     }
 }

crates/vim/src/object.rs 🔗

@@ -369,7 +369,7 @@ fn surrounding_markers(
                     start = Some(point)
                 } else {
                     *point.column_mut() += char.len_utf8() as u32;
-                    start = Some(point);
+                    start = Some(point)
                 }
                 break;
             }
@@ -420,11 +420,38 @@ fn surrounding_markers(
         }
     }
 
-    if let (Some(start), Some(end)) = (start, end) {
-        Some(start..end)
-    } else {
-        None
+    let (Some(mut start), Some(mut end)) = (start, end) else {
+        return None;
+    };
+
+    if !around {
+        // if a block starts with a newline, move the start to after the newline.
+        let mut was_newline = false;
+        for (char, point) in map.chars_at(start) {
+            if was_newline {
+                start = point;
+            } else if char == '\n' {
+                was_newline = true;
+                continue;
+            }
+            break;
+        }
+        // if a block ends with a newline, then whitespace, then the delimeter,
+        // move the end to after the newline.
+        let mut new_end = end;
+        for (char, point) in map.reverse_chars_at(end) {
+            if char == '\n' {
+                end = new_end;
+                break;
+            }
+            if !char.is_whitespace() {
+                break;
+            }
+            new_end = point
+        }
     }
+
+    Some(start..end)
 }
 
 #[cfg(test)]
@@ -481,6 +508,12 @@ mod test {
     async fn test_visual_word_object(cx: &mut gpui::TestAppContext) {
         let mut cx = NeovimBackedTestContext::new(cx).await;
 
+        cx.set_shared_state("The quick ˇbrown\nfox").await;
+        cx.simulate_shared_keystrokes(["v"]).await;
+        cx.assert_shared_state("The quick «bˇ»rown\nfox").await;
+        cx.simulate_shared_keystrokes(["i", "w"]).await;
+        cx.assert_shared_state("The quick «brownˇ»\nfox").await;
+
         cx.assert_binding_matches_all(["v", "i", "w"], WORD_LOCATIONS)
             .await;
         cx.assert_binding_matches_all_exempted(
@@ -675,6 +708,48 @@ mod test {
         }
     }
 
+    #[gpui::test]
+    async fn test_multiline_surrounding_character_objects(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        cx.set_shared_state(indoc! {
+            "func empty(a string) bool {
+               if a == \"\" {
+                  return true
+               }
+               ˇreturn false
+            }"
+        })
+        .await;
+        cx.simulate_shared_keystrokes(["v", "i", "{"]).await;
+        cx.assert_shared_state(indoc! {"
+            func empty(a string) bool {
+            «   if a == \"\" {
+                  return true
+               }
+               return false
+            ˇ»}"})
+            .await;
+        cx.set_shared_state(indoc! {
+            "func empty(a string) bool {
+                 if a == \"\" {
+                     ˇreturn true
+                 }
+                 return false
+            }"
+        })
+        .await;
+        cx.simulate_shared_keystrokes(["v", "i", "{"]).await;
+        cx.assert_shared_state(indoc! {"
+            func empty(a string) bool {
+                 if a == \"\" {
+            «         return true
+            ˇ»     }
+                 return false
+            }"})
+            .await;
+    }
+
     #[gpui::test]
     async fn test_delete_surrounding_character_objects(cx: &mut gpui::TestAppContext) {
         let mut cx = NeovimBackedTestContext::new(cx).await;

crates/vim/src/state.rs 🔗

@@ -12,6 +12,15 @@ pub enum Mode {
     Visual { line: bool },
 }
 
+impl Mode {
+    pub fn is_visual(&self) -> bool {
+        match self {
+            Mode::Normal | Mode::Insert => false,
+            Mode::Visual { .. } => true,
+        }
+    }
+}
+
 impl Default for Mode {
     fn default() -> Self {
         Self::Normal
@@ -78,12 +87,11 @@ impl VimState {
             )
     }
 
-    pub fn clip_at_line_end(&self) -> bool {
-        !matches!(self.mode, Mode::Insert | Mode::Visual { .. })
-    }
-
-    pub fn empty_selections_only(&self) -> bool {
-        !matches!(self.mode, Mode::Visual { .. })
+    pub fn clip_at_line_ends(&self) -> bool {
+        match self.mode {
+            Mode::Insert | Mode::Visual { .. } => false,
+            Mode::Normal => true,
+        }
     }
 
     pub fn keymap_context_layer(&self) -> KeymapContext {

crates/vim/src/test.rs 🔗

@@ -141,7 +141,7 @@ async fn test_indent_outdent(cx: &mut gpui::TestAppContext) {
 
     // works in visuial mode
     cx.simulate_keystrokes(["shift-v", "down", ">"]);
-    cx.assert_editor_state("aa\n    b«b\n    cˇ»c");
+    cx.assert_editor_state("aa\n    b«b\n    ccˇ»");
 }
 
 #[gpui::test]
@@ -157,6 +157,16 @@ async fn test_escape_command_palette(cx: &mut gpui::TestAppContext) {
     cx.assert_state("aˇbc\n", Mode::Insert);
 }
 
+#[gpui::test]
+async fn test_escape_cancels(cx: &mut gpui::TestAppContext) {
+    let mut cx = VimTestContext::new(cx, true).await;
+
+    cx.set_state("aˇbˇc", Mode::Normal);
+    cx.simulate_keystrokes(["escape"]);
+
+    cx.assert_state("aˇbc", Mode::Normal);
+}
+
 #[gpui::test]
 async fn test_selection_on_search(cx: &mut gpui::TestAppContext) {
     let mut cx = VimTestContext::new(cx, true).await;

crates/vim/src/test/neovim_backed_test_context.rs 🔗

@@ -61,6 +61,9 @@ pub struct NeovimBackedTestContext<'a> {
     // bindings are exempted. If None, all bindings are ignored for that insertion text.
     exemptions: HashMap<String, Option<HashSet<String>>>,
     neovim: NeovimConnection,
+
+    last_set_state: Option<String>,
+    recent_keystrokes: Vec<String>,
 }
 
 impl<'a> NeovimBackedTestContext<'a> {
@@ -71,6 +74,9 @@ impl<'a> NeovimBackedTestContext<'a> {
             cx,
             exemptions: Default::default(),
             neovim: NeovimConnection::new(function_name).await,
+
+            last_set_state: None,
+            recent_keystrokes: Default::default(),
         }
     }
 
@@ -102,13 +108,21 @@ impl<'a> NeovimBackedTestContext<'a> {
         keystroke_texts: [&str; COUNT],
     ) -> ContextHandle {
         for keystroke_text in keystroke_texts.into_iter() {
+            self.recent_keystrokes.push(keystroke_text.to_string());
             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 mode = if marked_text.contains("»") {
+            Mode::Visual { line: false }
+        } else {
+            Mode::Normal
+        };
+        let context_handle = self.set_state(marked_text, mode);
+        self.last_set_state = Some(marked_text.to_string());
+        self.recent_keystrokes = Vec::new();
         self.neovim.set_state(marked_text).await;
         context_handle
     }
@@ -116,15 +130,25 @@ impl<'a> NeovimBackedTestContext<'a> {
     pub async fn assert_shared_state(&mut self, marked_text: &str) {
         let neovim = self.neovim_state().await;
         if neovim != marked_text {
+            let initial_state = self
+                .last_set_state
+                .as_ref()
+                .unwrap_or(&"N/A".to_string())
+                .clone();
             panic!(
                 indoc! {"Test is incorrect (currently expected != neovim state)
-
+                # initial state:
+                {}
+                # keystrokes:
+                {}
                 # currently expected:
                 {}
                 # neovim state:
                 {}
                 # zed state:
                 {}"},
+                initial_state,
+                self.recent_keystrokes.join(" "),
                 marked_text,
                 neovim,
                 self.editor_state(),
@@ -141,28 +165,40 @@ impl<'a> NeovimBackedTestContext<'a> {
         )
     }
 
+    pub async fn neovim_mode(&mut self) -> Mode {
+        self.neovim.mode().await.unwrap()
+    }
+
     async fn neovim_selection(&mut self) -> Range<usize> {
-        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 = self.neovim.selection().await;
         neovim_selection.to_offset(&self.buffer_snapshot())
     }
 
     pub async fn assert_state_matches(&mut self) {
-        assert_eq!(
-            self.neovim.text().await,
-            self.buffer_text(),
-            "{}",
-            self.assertion_context()
-        );
-
-        let selections = vec![self.neovim_selection().await];
-        self.assert_editor_selections(selections);
-
-        if let Some(neovim_mode) = self.neovim.mode().await {
-            assert_eq!(neovim_mode, self.mode(), "{}", self.assertion_context(),);
+        let neovim = self.neovim_state().await;
+        let editor = self.editor_state();
+        let initial_state = self
+            .last_set_state
+            .as_ref()
+            .unwrap_or(&"N/A".to_string())
+            .clone();
+
+        if neovim != editor {
+            panic!(
+                indoc! {"Test failed (zed does not match nvim behaviour)
+                    # initial state:
+                    {}
+                    # keystrokes:
+                    {}
+                    # neovim state:
+                    {}
+                    # zed state:
+                    {}"},
+                initial_state,
+                self.recent_keystrokes.join(" "),
+                neovim,
+                editor,
+            )
         }
     }
 
@@ -207,6 +243,29 @@ impl<'a> NeovimBackedTestContext<'a> {
         }
     }
 
+    pub fn each_marked_position(&self, marked_positions: &str) -> Vec<String> {
+        let (unmarked_text, cursor_offsets) = marked_text_offsets(marked_positions);
+        let mut ret = Vec::with_capacity(cursor_offsets.len());
+
+        for cursor_offset in cursor_offsets.iter() {
+            let mut marked_text = unmarked_text.clone();
+            marked_text.insert(*cursor_offset, 'ˇ');
+            ret.push(marked_text)
+        }
+
+        ret
+    }
+
+    pub async fn assert_neovim_compatible<const COUNT: usize>(
+        &mut self,
+        marked_positions: &str,
+        keystrokes: [&str; COUNT],
+    ) {
+        self.set_shared_state(&marked_positions).await;
+        self.simulate_shared_keystrokes(keystrokes).await;
+        self.assert_state_matches().await;
+    }
+
     pub async fn assert_binding_matches_all_exempted<const COUNT: usize>(
         &mut self,
         keystrokes: [&str; COUNT],

crates/vim/src/test/neovim_connection.rs 🔗

@@ -213,6 +213,16 @@ impl NeovimConnection {
         );
     }
 
+    #[cfg(feature = "neovim")]
+    async fn read_position(&mut self, cmd: &str) -> u32 {
+        self.nvim
+            .command_output(cmd)
+            .await
+            .unwrap()
+            .parse::<u32>()
+            .unwrap()
+    }
+
     #[cfg(feature = "neovim")]
     pub async fn state(&mut self) -> (Option<Mode>, String, Range<Point>) {
         let nvim_buffer = self
@@ -226,22 +236,12 @@ impl NeovimConnection {
             .expect("Could not get buffer text")
             .join("\n");
 
-        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
+        // nvim columns are 1-based, so -1.
+        let mut cursor_row = self.read_position("echo line('.')").await - 1;
+        let mut cursor_col = self.read_position("echo col('.')").await - 1;
+        let mut selection_row = self.read_position("echo line('v')").await - 1;
+        let mut selection_col = self.read_position("echo col('v')").await - 1;
+        let total_rows = self.read_position("echo line('$')").await - 1;
 
         let nvim_mode_text = self
             .nvim
@@ -266,46 +266,38 @@ impl NeovimConnection {
             _ => None,
         };
 
-        let (start, end) = if let Some(Mode::Visual { .. }) = mode {
-            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 {
-                (
-                    Point::new(end_row as u32 - 1, end_col as u32),
-                    Point::new(start_row as u32 - 1, start_col as u32),
-                )
-            } else {
-                (
-                    Point::new(start_row as u32 - 1, start_col as u32),
-                    Point::new(end_row as u32 - 1, end_col as u32),
-                )
+        // Vim uses the index of the first and last character in the selection
+        // Zed uses the index of the positions between the characters, so we need
+        // to add one to the end in visual mode.
+        match mode {
+            Some(Mode::Visual { .. }) => {
+                if selection_col > cursor_col {
+                    let selection_line_length =
+                        self.read_position("echo strlen(getline(line('v')))").await;
+                    if selection_line_length > selection_col {
+                        selection_col += 1;
+                    } else if selection_row < total_rows {
+                        selection_col = 0;
+                        selection_row += 1;
+                    }
+                } else {
+                    let cursor_line_length =
+                        self.read_position("echo strlen(getline(line('.')))").await;
+                    if cursor_line_length > cursor_col {
+                        cursor_col += 1;
+                    } else if cursor_row < total_rows {
+                        cursor_col = 0;
+                        cursor_row += 1;
+                    }
+                }
             }
-        } else {
-            (
-                Point::new(cursor_row, cursor_col),
-                Point::new(cursor_row, cursor_col),
-            )
-        };
+            Some(Mode::Insert) | Some(Mode::Normal) | None => {}
+        }
+
+        let (start, end) = (
+            Point::new(selection_row, selection_col),
+            Point::new(cursor_row, cursor_col),
+        );
 
         let state = NeovimData::Get {
             mode,

crates/vim/src/test/vim_test_context.rs 🔗

@@ -86,12 +86,13 @@ impl<'a> VimTestContext<'a> {
 
     pub fn set_state(&mut self, text: &str, mode: Mode) -> ContextHandle {
         let window = self.window;
+        let context_handle = self.cx.set_state(text);
         window.update(self.cx.cx.cx, |cx| {
             Vim::update(cx, |vim, cx| {
-                vim.switch_mode(mode, false, cx);
+                vim.switch_mode(mode, true, cx);
             })
         });
-        self.cx.set_state(text)
+        context_handle
     }
 
     #[track_caller]

crates/vim/src/vim.rs 🔗

@@ -13,7 +13,7 @@ mod visual;
 
 use anyhow::Result;
 use collections::CommandPaletteFilter;
-use editor::{Bias, Editor, EditorMode, Event};
+use editor::{movement, Editor, EditorMode, Event};
 use gpui::{
     actions, impl_actions, keymap_matcher::KeymapContext, keymap_matcher::MatchResult, AppContext,
     Subscription, ViewContext, ViewHandle, WeakViewHandle, WindowContext,
@@ -181,6 +181,7 @@ impl Vim {
     }
 
     fn switch_mode(&mut self, mode: Mode, leave_selections: bool, cx: &mut WindowContext) {
+        let last_mode = self.state.mode;
         self.state.mode = mode;
         self.state.operator_stack.clear();
 
@@ -197,12 +198,16 @@ impl Vim {
         self.update_active_editor(cx, |editor, cx| {
             editor.change_selections(None, cx, |s| {
                 s.move_with(|map, selection| {
-                    if self.state.empty_selections_only() {
-                        let new_head = map.clip_point(selection.head(), Bias::Left);
-                        selection.collapse_to(new_head, selection.goal)
-                    } else {
-                        selection
-                            .set_head(map.clip_point(selection.head(), Bias::Left), selection.goal);
+                    if last_mode.is_visual() && !mode.is_visual() {
+                        let mut point = selection.head();
+                        if !selection.reversed {
+                            point = movement::left(map, selection.head());
+                        }
+                        selection.collapse_to(point, selection.goal)
+                    } else if !last_mode.is_visual() && mode.is_visual() {
+                        if selection.is_empty() {
+                            selection.end = movement::right(map, selection.start);
+                        }
                     }
                 });
             })
@@ -265,7 +270,7 @@ impl Vim {
             }
             Some(Operator::Replace) => match Vim::read(cx).state.mode {
                 Mode::Normal => normal_replace(text, cx),
-                Mode::Visual { line } => visual_replace(text, line, cx),
+                Mode::Visual { .. } => visual_replace(text, cx),
                 _ => Vim::update(cx, |vim, cx| vim.clear_operator(cx)),
             },
             _ => {}
@@ -309,7 +314,7 @@ impl Vim {
         self.update_active_editor(cx, |editor, cx| {
             if self.enabled && editor.mode() == EditorMode::Full {
                 editor.set_cursor_shape(cursor_shape, cx);
-                editor.set_clip_at_line_ends(state.clip_at_line_end(), cx);
+                editor.set_clip_at_line_ends(state.clip_at_line_ends(), cx);
                 editor.set_collapse_matches(true);
                 editor.set_input_enabled(!state.vim_controlled());
                 editor.selections.line_mode = matches!(state.mode, Mode::Visual { line: true });

crates/vim/src/visual.rs 🔗

@@ -16,10 +16,22 @@ use crate::{
     Vim,
 };
 
-actions!(vim, [VisualDelete, VisualChange, VisualYank, VisualPaste]);
+actions!(
+    vim,
+    [
+        ToggleVisual,
+        ToggleVisualLine,
+        VisualDelete,
+        VisualYank,
+        VisualPaste,
+        OtherEnd,
+    ]
+);
 
 pub fn init(cx: &mut AppContext) {
-    cx.add_action(change);
+    cx.add_action(toggle_visual);
+    cx.add_action(toggle_visual_line);
+    cx.add_action(other_end);
     cx.add_action(delete);
     cx.add_action(yank);
     cx.add_action(paste);
@@ -32,24 +44,45 @@ pub fn visual_motion(motion: Motion, times: Option<usize>, cx: &mut WindowContex
                 s.move_with(|map, selection| {
                     let was_reversed = selection.reversed;
 
-                    if let Some((new_head, goal)) =
-                        motion.move_point(map, selection.head(), selection.goal, times)
+                    let mut current_head = selection.head();
+
+                    // our motions assume the current character is after the cursor,
+                    // but in (forward) visual mode the current character is just
+                    // before the end of the selection.
+
+                    // If the file ends with a newline (which is common) we don't do this.
+                    // so that if you go to the end of such a file you can use "up" to go
+                    // to the previous line and have it work somewhat as expected.
+                    if !selection.reversed
+                        && !selection.is_empty()
+                        && !(selection.end.column() == 0 && selection.end == map.max_point())
                     {
-                        selection.set_head(new_head, goal);
-
-                        if was_reversed && !selection.reversed {
-                            // Head was at the start of the selection, and now is at the end. We need to move the start
-                            // back by one if possible in order to compensate for this change.
-                            *selection.start.column_mut() =
-                                selection.start.column().saturating_sub(1);
-                            selection.start = map.clip_point(selection.start, Bias::Left);
-                        } else if !was_reversed && selection.reversed {
-                            // Head was at the end of the selection, and now is at the start. We need to move the end
-                            // forward by one if possible in order to compensate for this change.
-                            *selection.end.column_mut() = selection.end.column() + 1;
-                            selection.end = map.clip_point(selection.end, Bias::Right);
+                        current_head = movement::left(map, selection.end)
+                    }
+
+                    let Some((new_head, goal)) =
+                        motion.move_point(map, current_head, selection.goal, times) else { return };
+
+                    selection.set_head(new_head, goal);
+
+                    // ensure the current character is included in the selection.
+                    if !selection.reversed {
+                        // TODO: maybe try clipping left for multi-buffers
+                        let next_point = movement::right(map, selection.end);
+
+                        if !(next_point.column() == 0 && next_point == map.max_point()) {
+                            selection.end = movement::right(map, selection.end)
                         }
                     }
+
+                    // vim always ensures the anchor character stays selected.
+                    // if our selection has reversed, we need to move the opposite end
+                    // to ensure the anchor is still selected.
+                    if was_reversed && !selection.reversed {
+                        selection.start = movement::left(map, selection.start);
+                    } else if !was_reversed && selection.reversed {
+                        selection.end = movement::right(map, selection.end);
+                    }
                 });
             });
         });
@@ -64,14 +97,29 @@ pub fn visual_object(object: Object, cx: &mut WindowContext) {
             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) {
+                        let mut head = selection.head();
+
+                        // all our motions assume that the current character is
+                        // after the cursor; however in the case of a visual selection
+                        // the current character is before the cursor.
+                        if !selection.reversed {
+                            head = movement::left(map, head);
+                        }
+
+                        if let Some(range) = object.range(map, head, around) {
                             if !range.is_empty() {
-                                if let Some((_, end)) = map.reverse_chars_at(range.end).next() {
-                                    range.end = end;
-                                }
+                                let expand_both_ways = if selection.is_empty() {
+                                    true
+                                // contains only one character
+                                } else if let Some((_, start)) =
+                                    map.reverse_chars_at(selection.end).next()
+                                {
+                                    selection.start == start
+                                } else {
+                                    false
+                                };
 
-                                if selection.is_empty() {
+                                if expand_both_ways {
                                     selection.start = range.start;
                                     selection.end = range.end;
                                 } else if selection.reversed {
@@ -88,72 +136,58 @@ pub fn visual_object(object: Object, cx: &mut WindowContext) {
     });
 }
 
-pub fn change(_: &mut Workspace, _: &VisualChange, cx: &mut ViewContext<Workspace>) {
+pub fn toggle_visual(_: &mut Workspace, _: &ToggleVisual, cx: &mut ViewContext<Workspace>) {
+    Vim::update(cx, |vim, cx| match vim.state.mode {
+        Mode::Normal | Mode::Insert | Mode::Visual { line: true } => {
+            vim.switch_mode(Mode::Visual { line: false }, false, cx);
+        }
+        Mode::Visual { line: false } => {
+            vim.switch_mode(Mode::Normal, false, cx);
+        }
+    })
+}
+
+pub fn toggle_visual_line(
+    _: &mut Workspace,
+    _: &ToggleVisualLine,
+    cx: &mut ViewContext<Workspace>,
+) {
+    Vim::update(cx, |vim, cx| match vim.state.mode {
+        Mode::Normal | Mode::Insert | Mode::Visual { line: false } => {
+            vim.switch_mode(Mode::Visual { line: true }, false, cx);
+        }
+        Mode::Visual { line: true } => {
+            vim.switch_mode(Mode::Normal, false, cx);
+        }
+    })
+}
+
+pub fn other_end(_: &mut Workspace, _: &OtherEnd, cx: &mut ViewContext<Workspace>) {
     Vim::update(cx, |vim, cx| {
         vim.update_active_editor(cx, |editor, cx| {
-            editor.set_clip_at_line_ends(false, cx);
-            // Compute edits and resulting anchor selections. If in line mode, adjust
-            // the anchor location and additional newline
-            let mut edits = Vec::new();
-            let mut new_selections = Vec::new();
-            let line_mode = editor.selections.line_mode;
             editor.change_selections(None, cx, |s| {
-                s.move_with(|map, selection| {
-                    if !selection.reversed {
-                        // Head is at the end of the selection. Adjust the end position to
-                        // to include the character under the cursor.
-                        *selection.end.column_mut() = selection.end.column() + 1;
-                        selection.end = map.clip_point(selection.end, Bias::Right);
-                    }
-
-                    if line_mode {
-                        let range = selection.map(|p| p.to_point(map)).range();
-                        let expanded_range = map.expand_to_line(range);
-                        // If we are at the last line, the anchor needs to be after the newline so that
-                        // it is on a line of its own. Otherwise, the anchor may be after the newline
-                        let anchor = if expanded_range.end == map.buffer_snapshot.max_point() {
-                            map.buffer_snapshot.anchor_after(expanded_range.end)
-                        } else {
-                            map.buffer_snapshot.anchor_before(expanded_range.start)
-                        };
-
-                        edits.push((expanded_range, "\n"));
-                        new_selections.push(selection.map(|_| anchor));
-                    } else {
-                        let range = selection.map(|p| p.to_point(map)).range();
-                        let anchor = map.buffer_snapshot.anchor_after(range.end);
-                        edits.push((range, ""));
-                        new_selections.push(selection.map(|_| anchor));
-                    }
-                    selection.goal = SelectionGoal::None;
-                });
-            });
-            copy_selections_content(editor, editor.selections.line_mode, cx);
-            editor.edit_with_autoindent(edits, cx);
-            editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
-                s.select_anchors(new_selections);
-            });
-        });
-        vim.switch_mode(Mode::Insert, false, cx);
+                s.move_with(|_, selection| {
+                    selection.reversed = !selection.reversed;
+                })
+            })
+        })
     });
 }
 
 pub fn delete(_: &mut Workspace, _: &VisualDelete, cx: &mut ViewContext<Workspace>) {
     Vim::update(cx, |vim, cx| {
         vim.update_active_editor(cx, |editor, cx| {
-            editor.set_clip_at_line_ends(false, cx);
             let mut original_columns: HashMap<_, _> = Default::default();
             let line_mode = editor.selections.line_mode;
+
             editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
                 s.move_with(|map, selection| {
                     if line_mode {
-                        original_columns
-                            .insert(selection.id, selection.head().to_point(map).column);
-                    } else if !selection.reversed {
-                        // Head is at the end of the selection. Adjust the end position to
-                        // to include the character under the cursor.
-                        *selection.end.column_mut() = selection.end.column() + 1;
-                        selection.end = map.clip_point(selection.end, Bias::Right);
+                        let mut position = selection.head();
+                        if !selection.reversed {
+                            position = movement::left(map, position);
+                        }
+                        original_columns.insert(selection.id, position.to_point(map).column);
                     }
                     selection.goal = SelectionGoal::None;
                 });
@@ -175,27 +209,14 @@ pub fn delete(_: &mut Workspace, _: &VisualDelete, cx: &mut ViewContext<Workspac
                 });
             });
         });
-        vim.switch_mode(Mode::Normal, false, cx);
+        vim.switch_mode(Mode::Normal, true, cx);
     });
 }
 
 pub fn yank(_: &mut Workspace, _: &VisualYank, cx: &mut ViewContext<Workspace>) {
     Vim::update(cx, |vim, cx| {
         vim.update_active_editor(cx, |editor, cx| {
-            editor.set_clip_at_line_ends(false, cx);
             let line_mode = editor.selections.line_mode;
-            if !line_mode {
-                editor.change_selections(None, cx, |s| {
-                    s.move_with(|map, selection| {
-                        if !selection.reversed {
-                            // Head is at the end of the selection. Adjust the end position to
-                            // to include the character under the cursor.
-                            *selection.end.column_mut() = selection.end.column() + 1;
-                            selection.end = map.clip_point(selection.end, Bias::Right);
-                        }
-                    });
-                });
-            }
             copy_selections_content(editor, line_mode, cx);
             editor.change_selections(None, cx, |s| {
                 s.move_with(|_, selection| {
@@ -203,7 +224,7 @@ pub fn yank(_: &mut Workspace, _: &VisualYank, cx: &mut ViewContext<Workspace>)
                 });
             });
         });
-        vim.switch_mode(Mode::Normal, false, cx);
+        vim.switch_mode(Mode::Normal, true, cx);
     });
 }
 
@@ -256,11 +277,7 @@ pub fn paste(_: &mut Workspace, _: &VisualPaste, cx: &mut ViewContext<Workspace>
 
                                 let mut selection = selection.clone();
                                 if !selection.reversed {
-                                    let mut adjusted = selection.end;
-                                    // Head is at the end of the selection. Adjust the end position to
-                                    // to include the character under the cursor.
-                                    *adjusted.column_mut() = adjusted.column() + 1;
-                                    adjusted = display_map.clip_point(adjusted, Bias::Right);
+                                    let adjusted = selection.end;
                                     // If the selection is empty, move both the start and end forward one
                                     // character
                                     if selection.is_empty() {
@@ -311,11 +328,11 @@ pub fn paste(_: &mut Workspace, _: &VisualPaste, cx: &mut ViewContext<Workspace>
                 }
             });
         });
-        vim.switch_mode(Mode::Normal, false, cx);
+        vim.switch_mode(Mode::Normal, true, cx);
     });
 }
 
-pub(crate) fn visual_replace(text: Arc<str>, line: bool, cx: &mut WindowContext) {
+pub(crate) fn visual_replace(text: Arc<str>, cx: &mut WindowContext) {
     Vim::update(cx, |vim, cx| {
         vim.update_active_editor(cx, |editor, cx| {
             editor.transact(cx, |editor, cx| {
@@ -336,14 +353,7 @@ pub(crate) fn visual_replace(text: Arc<str>, line: bool, cx: &mut WindowContext)
 
                 let mut edits = Vec::new();
                 for selection in selections.iter() {
-                    let mut selection = selection.clone();
-                    if !line && !selection.reversed {
-                        // Head is at the end of the selection. Adjust the end position to
-                        // to include the character under the cursor.
-                        *selection.end.column_mut() = selection.end.column() + 1;
-                        selection.end = display_map.clip_point(selection.end, Bias::Right);
-                    }
-
+                    let selection = selection.clone();
                     for row_range in
                         movement::split_display_range_by_lines(&display_map, selection.range())
                     {
@@ -367,6 +377,7 @@ pub(crate) fn visual_replace(text: Arc<str>, line: bool, cx: &mut WindowContext)
 #[cfg(test)]
 mod test {
     use indoc::indoc;
+    use workspace::item::Item;
 
     use crate::{
         state::Mode,
@@ -375,19 +386,146 @@ mod test {
 
     #[gpui::test]
     async fn test_enter_visual_mode(cx: &mut gpui::TestAppContext) {
-        let mut cx = NeovimBackedTestContext::new(cx)
-            .await
-            .binding(["v", "w", "j"]);
-        cx.assert_all(indoc! {"
-                The ˇquick brown
-                fox jumps ˇover
-                the ˇlazy dog"})
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        cx.set_shared_state(indoc! {
+            "The ˇquick brown
+            fox jumps over
+            the lazy dog"
+        })
+        .await;
+        let cursor = cx.update_editor(|editor, _| editor.pixel_position_of_cursor());
+
+        // entering visual mode should select the character
+        // under cursor
+        cx.simulate_shared_keystrokes(["v"]).await;
+        cx.assert_shared_state(indoc! { "The «qˇ»uick brown
+            fox jumps over
+            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"})
+        cx.update_editor(|editor, _| assert_eq!(cursor, editor.pixel_position_of_cursor()));
+
+        // forwards motions should extend the selection
+        cx.simulate_shared_keystrokes(["w", "j"]).await;
+        cx.assert_shared_state(indoc! { "The «quick brown
+            fox jumps oˇ»ver
+            the lazy dog"})
+            .await;
+
+        cx.simulate_shared_keystrokes(["escape"]).await;
+        assert_eq!(Mode::Normal, cx.neovim_mode().await);
+        cx.assert_shared_state(indoc! { "The quick brown
+            fox jumps ˇover
+            the lazy dog"})
+            .await;
+
+        // motions work backwards
+        cx.simulate_shared_keystrokes(["v", "k", "b"]).await;
+        cx.assert_shared_state(indoc! { "The «ˇquick brown
+            fox jumps o»ver
+            the lazy dog"})
+            .await;
+
+        // works on empty lines
+        cx.set_shared_state(indoc! {"
+            a
+            ˇ
+            b
+            "})
+            .await;
+        let cursor = cx.update_editor(|editor, _| editor.pixel_position_of_cursor());
+        cx.simulate_shared_keystrokes(["v"]).await;
+        cx.assert_shared_state(indoc! {"
+            a
+            «
+            ˇ»b
+        "})
+            .await;
+        cx.update_editor(|editor, _| assert_eq!(cursor, editor.pixel_position_of_cursor()));
+
+        // toggles off again
+        cx.simulate_shared_keystrokes(["v"]).await;
+        cx.assert_shared_state(indoc! {"
+            a
+            ˇ
+            b
+            "})
+            .await;
+
+        // works at the end of a document
+        cx.set_shared_state(indoc! {"
+            a
+            b
+            ˇ"})
+            .await;
+
+        cx.simulate_shared_keystrokes(["v"]).await;
+        cx.assert_shared_state(indoc! {"
+            a
+            b
+            ˇ"})
+            .await;
+        assert_eq!(cx.mode(), cx.neovim_mode().await);
+    }
+
+    #[gpui::test]
+    async fn test_enter_visual_line_mode(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        cx.set_shared_state(indoc! {
+            "The ˇquick brown
+            fox jumps over
+            the lazy dog"
+        })
+        .await;
+        cx.simulate_shared_keystrokes(["shift-v"]).await;
+        cx.assert_shared_state(indoc! { "The «qˇ»uick brown
+            fox jumps over
+            the lazy dog"})
+            .await;
+        assert_eq!(cx.mode(), cx.neovim_mode().await);
+        cx.simulate_shared_keystrokes(["x"]).await;
+        cx.assert_shared_state(indoc! { "fox ˇjumps over
+        the lazy dog"})
+            .await;
+
+        // it should work on empty lines
+        cx.set_shared_state(indoc! {"
+            a
+            ˇ
+            b"})
+            .await;
+        cx.simulate_shared_keystrokes(["shift-v"]).await;
+        cx.assert_shared_state(indoc! { "
+            a
+            «
+            ˇ»b"})
+            .await;
+        cx.simulate_shared_keystrokes(["x"]).await;
+        cx.assert_shared_state(indoc! { "
+            a
+            ˇb"})
+            .await;
+
+        // it should work at the end of the document
+        cx.set_shared_state(indoc! {"
+            a
+            b
+            ˇ"})
+            .await;
+        let cursor = cx.update_editor(|editor, _| editor.pixel_position_of_cursor());
+        cx.simulate_shared_keystrokes(["shift-v"]).await;
+        cx.assert_shared_state(indoc! {"
+            a
+            b
+            ˇ"})
+            .await;
+        assert_eq!(cx.mode(), cx.neovim_mode().await);
+        cx.update_editor(|editor, _| assert_eq!(cursor, editor.pixel_position_of_cursor()));
+        cx.simulate_shared_keystrokes(["x"]).await;
+        cx.assert_shared_state(indoc! {"
+            a
+            ˇb"})
             .await;
     }
 
@@ -395,6 +533,9 @@ mod test {
     async fn test_visual_delete(cx: &mut gpui::TestAppContext) {
         let mut cx = NeovimBackedTestContext::new(cx).await;
 
+        cx.assert_binding_matches(["v", "w"], "The quick ˇbrown")
+            .await;
+
         cx.assert_binding_matches(["v", "w", "x"], "The quick ˇbrown")
             .await;
         cx.assert_binding_matches(
@@ -457,62 +598,15 @@ mod test {
                 fox juˇmps over
                 the laˇzy dog"})
             .await;
-    }
-
-    #[gpui::test]
-    async fn test_visual_change(cx: &mut gpui::TestAppContext) {
-        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"})
-            .await;
-        let mut cx = cx.binding(["v", "b", "k", "c"]);
-        cx.assert_all(indoc! {"
-                The ˇquick brown
-                fox jumps ˇover
-                the ˇlazy dog"})
-            .await;
-    }
-
-    #[gpui::test]
-    async fn test_visual_line_change(cx: &mut gpui::TestAppContext) {
-        let mut cx = NeovimBackedTestContext::new(cx)
-            .await
-            .binding(["shift-v", "c"]);
-        cx.assert(indoc! {"
-                The quˇick brown
-                fox jumps over
-                the lazy dog"})
-            .await;
-        // Test pasting code copied on change
-        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 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"})
+        cx.set_shared_state(indoc! {"
+            The ˇlong line
+            should not
+            crash
+            "})
             .await;
-        // Test pasting code copied on delete
-        cx.simulate_shared_keystrokes(["escape", "j", "p"]).await;
+        cx.simulate_shared_keystrokes(["shift-v", "$", "x"]).await;
         cx.assert_state_matches().await;
-
-        cx.assert_all(indoc! {"
-                The quick brown
-                fox juˇmps over
-                the laˇzy dog"})
-            .await;
     }
 
     #[gpui::test]
@@ -605,7 +699,7 @@ mod test {
         cx.set_state(
             indoc! {"
                 The quick brown
-                fox «jumpˇ»s over
+                fox «jumpsˇ» over
                 the lazy dog"},
             Mode::Visual { line: false },
         );
@@ -629,7 +723,7 @@ mod test {
         cx.set_state(
             indoc! {"
                 The quick brown
-                fox juˇmps over
+                fox ju«mˇ»ps over
                 the lazy dog"},
             Mode::Visual { line: true },
         );
@@ -643,7 +737,7 @@ mod test {
         cx.set_state(
             indoc! {"
                 The quick brown
-                the «lazˇ»y dog"},
+                the «lazyˇ» dog"},
             Mode::Visual { line: false },
         );
         cx.simulate_keystroke("p");

crates/vim/test_data/test_enter_visual_line_mode.json 🔗

@@ -0,0 +1,15 @@
+{"Put":{"state":"The ˇquick brown\nfox jumps over\nthe lazy dog"}}
+{"Key":"shift-v"}
+{"Get":{"state":"The «qˇ»uick brown\nfox jumps over\nthe lazy dog","mode":{"Visual":{"line":true}}}}
+{"Key":"x"}
+{"Get":{"state":"fox ˇjumps over\nthe lazy dog","mode":"Normal"}}
+{"Put":{"state":"a\nˇ\nb"}}
+{"Key":"shift-v"}
+{"Get":{"state":"a\n«\nˇ»b","mode":{"Visual":{"line":true}}}}
+{"Key":"x"}
+{"Get":{"state":"a\nˇb","mode":"Normal"}}
+{"Put":{"state":"a\nb\nˇ"}}
+{"Key":"shift-v"}
+{"Get":{"state":"a\nb\nˇ","mode":{"Visual":{"line":true}}}}
+{"Key":"x"}
+{"Get":{"state":"a\nˇb","mode":"Normal"}}

crates/vim/test_data/test_enter_visual_mode.json 🔗

@@ -1,30 +1,20 @@
 {"Put":{"state":"The ˇquick brown\nfox jumps over\nthe lazy dog"}}
 {"Key":"v"}
+{"Get":{"state":"The «qˇ»uick brown\nfox jumps over\nthe lazy dog","mode":{"Visual":{"line":false}}}}
 {"Key":"w"}
 {"Key":"j"}
-{"Get":{"state":"The «quick brown\nfox jumps ˇ»over\nthe lazy dog","mode":{"Visual":{"line":false}}}}
-{"Put":{"state":"The quick brown\nfox jumps ˇover\nthe lazy dog"}}
+{"Get":{"state":"The «quick brown\nfox jumps oˇ»ver\nthe lazy dog","mode":{"Visual":{"line":false}}}}
+{"Key":"escape"}
+{"Get":{"state":"The quick brown\nfox jumps ˇover\nthe lazy dog","mode":"Normal"}}
 {"Key":"v"}
-{"Key":"w"}
-{"Key":"j"}
-{"Get":{"state":"The quick brown\nfox jumps «over\nˇ»the lazy dog","mode":{"Visual":{"line":false}}}}
-{"Put":{"state":"The quick brown\nfox jumps over\nthe ˇlazy dog"}}
-{"Key":"v"}
-{"Key":"w"}
-{"Key":"j"}
-{"Get":{"state":"The quick brown\nfox jumps over\nthe «lazy ˇ»dog","mode":{"Visual":{"line":false}}}}
-{"Put":{"state":"The ˇquick brown\nfox jumps over\nthe lazy dog"}}
-{"Key":"v"}
-{"Key":"b"}
 {"Key":"k"}
-{"Get":{"state":"«ˇThe »quick brown\nfox jumps over\nthe lazy dog","mode":{"Visual":{"line":false}}}}
-{"Put":{"state":"The quick brown\nfox jumps ˇover\nthe lazy dog"}}
-{"Key":"v"}
 {"Key":"b"}
-{"Key":"k"}
-{"Get":{"state":"The «ˇquick brown\nfox jumps »over\nthe lazy dog","mode":{"Visual":{"line":false}}}}
-{"Put":{"state":"The quick brown\nfox jumps over\nthe ˇlazy dog"}}
+{"Get":{"state":"The «ˇquick brown\nfox jumps o»ver\nthe lazy dog","mode":{"Visual":{"line":false}}}}
+{"Put":{"state":"a\nˇ\nb\n"}}
 {"Key":"v"}
-{"Key":"b"}
-{"Key":"k"}
-{"Get":{"state":"The quick brown\n«ˇfox jumps over\nthe »lazy dog","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"a\n«\nˇ»b\n","mode":{"Visual":{"line":false}}}}
+{"Key":"v"}
+{"Get":{"state":"a\nˇ\nb\n","mode":"Normal"}}
+{"Put":{"state":"a\nb\nˇ"}}
+{"Key":"v"}
+{"Get":{"state":"a\nb\nˇ","mode":{"Visual":{"line":false}}}}

crates/vim/test_data/test_multiline_surrounding_character_objects.json 🔗

@@ -0,0 +1,10 @@
+{"Put":{"state":"func empty(a string) bool {\n   if a == \"\" {\n      return true\n   }\n   ˇreturn false\n}"}}
+{"Key":"v"}
+{"Key":"i"}
+{"Key":"{"}
+{"Get":{"state":"func empty(a string) bool {\n«   if a == \"\" {\n      return true\n   }\n   return false\nˇ»}","mode":{"Visual":{"line":false}}}}
+{"Put":{"state":"func empty(a string) bool {\n     if a == \"\" {\n         ˇreturn true\n     }\n     return false\n}"}}
+{"Key":"v"}
+{"Key":"i"}
+{"Key":"{"}
+{"Get":{"state":"func empty(a string) bool {\n     if a == \"\" {\n«         return true\nˇ»     }\n     return false\n}","mode":{"Visual":{"line":false}}}}

crates/vim/test_data/test_visual_change.json 🔗

@@ -9,33 +9,39 @@
 {"Key":"j"}
 {"Key":"c"}
 {"Get":{"state":"The ˇver\nthe lazy dog","mode":"Insert"}}
-{"Put":{"state":"The quick brown\nfox jumps ˇover\nthe lazy dog"}}
+{"Put":{"state":"The ˇquick brown\nfox jumps over\nthe lazy dog"}}
 {"Key":"v"}
 {"Key":"w"}
 {"Key":"j"}
 {"Key":"c"}
-{"Get":{"state":"The quick brown\nfox jumps ˇhe lazy dog","mode":"Insert"}}
-{"Put":{"state":"The quick brown\nfox jumps over\nthe ˇlazy dog"}}
+{"Get":{"state":"The ˇver\nthe lazy dog","mode":"Insert"}}
+{"Put":{"state":"The ˇquick brown\nfox jumps over\nthe lazy dog"}}
 {"Key":"v"}
 {"Key":"w"}
-{"Key":"j"}
+{"Key":"k"}
 {"Key":"c"}
-{"Get":{"state":"The quick brown\nfox jumps over\nthe ˇog","mode":"Insert"}}
-{"Put":{"state":"The ˇquick brown\nfox jumps over\nthe lazy dog"}}
+{"Get":{"state":"The ˇrown\nfox jumps over\nthe lazy dog","mode":"Insert"}}
+{"Put":{"state":"The quick brown\nfox jumps ˇover\nthe lazy dog"}}
 {"Key":"v"}
-{"Key":"b"}
-{"Key":"k"}
+{"Key":"w"}
+{"Key":"j"}
 {"Key":"c"}
-{"Get":{"state":"ˇuick brown\nfox jumps over\nthe lazy dog","mode":"Insert"}}
+{"Get":{"state":"The quick brown\nfox jumps ˇhe lazy dog","mode":"Insert"}}
 {"Put":{"state":"The quick brown\nfox jumps ˇover\nthe lazy dog"}}
 {"Key":"v"}
-{"Key":"b"}
+{"Key":"w"}
 {"Key":"k"}
 {"Key":"c"}
-{"Get":{"state":"The ˇver\nthe lazy dog","mode":"Insert"}}
+{"Get":{"state":"The quick brown\nˇver\nthe lazy dog","mode":"Insert"}}
+{"Put":{"state":"The quick brown\nfox jumps over\nthe ˇlazy dog"}}
+{"Key":"v"}
+{"Key":"w"}
+{"Key":"j"}
+{"Key":"c"}
+{"Get":{"state":"The quick brown\nfox jumps over\nthe ˇog","mode":"Insert"}}
 {"Put":{"state":"The quick brown\nfox jumps over\nthe ˇlazy dog"}}
 {"Key":"v"}
-{"Key":"b"}
+{"Key":"w"}
 {"Key":"k"}
 {"Key":"c"}
-{"Get":{"state":"The quick brown\nˇazy dog","mode":"Insert"}}
+{"Get":{"state":"The quick brown\nfox jumpsˇazy dog","mode":"Insert"}}

crates/vim/test_data/test_visual_delete.json 🔗

@@ -1,6 +1,10 @@
 {"Put":{"state":"The quick ˇbrown"}}
 {"Key":"v"}
 {"Key":"w"}
+{"Get":{"state":"The quick «brownˇ»","mode":{"Visual":{"line":false}}}}
+{"Put":{"state":"The quick ˇbrown"}}
+{"Key":"v"}
+{"Key":"w"}
 {"Key":"x"}
 {"Get":{"state":"The quickˇ ","mode":"Normal"}}
 {"Put":{"state":"The ˇquick brown\nfox jumps over\nthe lazy dog"}}

crates/vim/test_data/test_visual_line_delete.json 🔗

@@ -29,3 +29,8 @@
 {"Key":"j"}
 {"Key":"x"}
 {"Get":{"state":"The quick brown\nfox juˇmps over","mode":"Normal"}}
+{"Put":{"state":"The ˇlong line\nshould not\ncrash\n"}}
+{"Key":"shift-v"}
+{"Key":"$"}
+{"Key":"x"}
+{"Get":{"state":"should noˇt\ncrash\n","mode":"Normal"}}

crates/vim/test_data/test_visual_word_object.json 🔗

@@ -1,230 +1,236 @@
+{"Put":{"state":"The quick ˇbrown\nfox"}}
+{"Key":"v"}
+{"Get":{"state":"The quick «bˇ»rown\nfox","mode":{"Visual":{"line":false}}}}
+{"Key":"i"}
+{"Key":"w"}
+{"Get":{"state":"The quick «brownˇ»\nfox","mode":{"Visual":{"line":false}}}}
 {"Put":{"state":"The quick ˇbrown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n"}}
 {"Key":"v"}
 {"Key":"i"}
 {"Key":"w"}
-{"Get":{"state":"The quick «browˇ»n   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick «brownˇ»   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
 {"Put":{"state":"The quick browˇn   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n"}}
 {"Key":"v"}
 {"Key":"i"}
 {"Key":"w"}
-{"Get":{"state":"The quick «browˇ»n   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick «brownˇ»   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
 {"Put":{"state":"The quick brownˇ   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n"}}
 {"Key":"v"}
 {"Key":"i"}
 {"Key":"w"}
-{"Get":{"state":"The quick brown«  ˇ» \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown«   ˇ»\nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
 {"Put":{"state":"The quick brown   \nfox ˇjumps over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n"}}
 {"Key":"v"}
 {"Key":"i"}
 {"Key":"w"}
-{"Get":{"state":"The quick brown   \nfox «jumpˇ»s over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown   \nfox «jumpsˇ» over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
 {"Put":{"state":"The quick brown   \nfox juˇmps over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n"}}
 {"Key":"v"}
 {"Key":"i"}
 {"Key":"w"}
-{"Get":{"state":"The quick brown   \nfox «jumpˇ»s over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown   \nfox «jumpsˇ» over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
 {"Put":{"state":"The quick brown   \nfox jumpsˇ over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n"}}
 {"Key":"v"}
 {"Key":"i"}
 {"Key":"w"}
-{"Get":{"state":"The quick brown   \nfox jumpsˇ over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown   \nfox jumps« ˇ»over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
 {"Put":{"state":"The quick brown   \nfox jumps over\nthe lazy dogˇ  \n\n\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n"}}
 {"Key":"v"}
 {"Key":"i"}
 {"Key":"w"}
-{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog« ˇ» \n\n\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog«  ˇ»\n\n\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
 {"Put":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \nˇ\n\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n"}}
 {"Key":"v"}
 {"Key":"i"}
 {"Key":"w"}
-{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \nˇ\n\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n«\nˇ»\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
 {"Put":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\nˇ\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n"}}
 {"Key":"v"}
 {"Key":"i"}
 {"Key":"w"}
-{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\nˇ\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n«\nˇ»\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
 {"Put":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\nˇ\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n"}}
 {"Key":"v"}
 {"Key":"i"}
 {"Key":"w"}
-{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\nˇ\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n«\nˇ»The-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
 {"Put":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThˇe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n"}}
 {"Key":"v"}
 {"Key":"i"}
 {"Key":"w"}
-{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\n«Thˇ»e-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\n«Theˇ»-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
 {"Put":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nTheˇ-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n"}}
 {"Key":"v"}
 {"Key":"i"}
 {"Key":"w"}
-{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nTheˇ-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe«-ˇ»quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
 {"Put":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-ˇquick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n"}}
 {"Key":"v"}
 {"Key":"i"}
 {"Key":"w"}
-{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-«quicˇ»k brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-«quickˇ» brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
 {"Put":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quˇick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n"}}
 {"Key":"v"}
 {"Key":"i"}
 {"Key":"w"}
-{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-«quicˇ»k brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-«quickˇ» brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
 {"Put":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quickˇ brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n"}}
 {"Key":"v"}
 {"Key":"i"}
 {"Key":"w"}
-{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quickˇ brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick« ˇ»brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
 {"Put":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick ˇbrown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n"}}
 {"Key":"v"}
 {"Key":"i"}
 {"Key":"w"}
-{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick «browˇ»n \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick «brownˇ» \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
 {"Put":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brownˇ \n  \n  \n  fox-jumps over\nthe lazy dog \n\n"}}
 {"Key":"v"}
 {"Key":"i"}
 {"Key":"w"}
-{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brownˇ \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brown« ˇ»\n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
 {"Put":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brown \nˇ  \n  \n  fox-jumps over\nthe lazy dog \n\n"}}
 {"Key":"v"}
 {"Key":"i"}
 {"Key":"w"}
-{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brown \n« ˇ» \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brown \n«  ˇ»\n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
 {"Put":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \nˇ  \n  fox-jumps over\nthe lazy dog \n\n"}}
 {"Key":"v"}
 {"Key":"i"}
 {"Key":"w"}
-{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n« ˇ» \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n«  ˇ»\n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
 {"Put":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \nˇ  fox-jumps over\nthe lazy dog \n\n"}}
 {"Key":"v"}
 {"Key":"i"}
 {"Key":"w"}
-{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \n« ˇ» fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \n«  ˇ»fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
 {"Put":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \n  fox-jumpˇs over\nthe lazy dog \n\n"}}
 {"Key":"v"}
 {"Key":"i"}
 {"Key":"w"}
-{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \n  fox-«jumpˇ»s over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \n  fox-«jumpsˇ» over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
 {"Put":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dogˇ \n\n"}}
 {"Key":"v"}
 {"Key":"i"}
 {"Key":"w"}
-{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dogˇ \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog« ˇ»\n\n","mode":{"Visual":{"line":false}}}}
 {"Put":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \nˇ\n"}}
 {"Key":"v"}
 {"Key":"i"}
 {"Key":"w"}
-{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \nˇ\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n«\nˇ»","mode":{"Visual":{"line":false}}}}
 {"Put":{"state":"The quick ˇbrown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n"}}
 {"Key":"v"}
 {"Key":"i"}
 {"Key":"shift-w"}
-{"Get":{"state":"The quick «browˇ»n   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick «brownˇ»   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
 {"Put":{"state":"The quick browˇn   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n"}}
 {"Key":"v"}
 {"Key":"i"}
 {"Key":"shift-w"}
-{"Get":{"state":"The quick «browˇ»n   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick «brownˇ»   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
 {"Put":{"state":"The quick brownˇ   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n"}}
 {"Key":"v"}
 {"Key":"i"}
 {"Key":"shift-w"}
-{"Get":{"state":"The quick brown«  ˇ» \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown«   ˇ»\nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
 {"Put":{"state":"The quick brown   \nfox ˇjumps over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n"}}
 {"Key":"v"}
 {"Key":"i"}
 {"Key":"shift-w"}
-{"Get":{"state":"The quick brown   \nfox «jumpˇ»s over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown   \nfox «jumpsˇ» over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
 {"Put":{"state":"The quick brown   \nfox juˇmps over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n"}}
 {"Key":"v"}
 {"Key":"i"}
 {"Key":"shift-w"}
-{"Get":{"state":"The quick brown   \nfox «jumpˇ»s over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown   \nfox «jumpsˇ» over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
 {"Put":{"state":"The quick brown   \nfox jumpsˇ over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n"}}
 {"Key":"v"}
 {"Key":"i"}
 {"Key":"shift-w"}
-{"Get":{"state":"The quick brown   \nfox jumpsˇ over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown   \nfox jumps« ˇ»over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
 {"Put":{"state":"The quick brown   \nfox jumps over\nthe lazy dogˇ  \n\n\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n"}}
 {"Key":"v"}
 {"Key":"i"}
 {"Key":"shift-w"}
-{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog« ˇ» \n\n\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog«  ˇ»\n\n\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
 {"Put":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \nˇ\n\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n"}}
 {"Key":"v"}
 {"Key":"i"}
 {"Key":"shift-w"}
-{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \nˇ\n\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n«\nˇ»\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
 {"Put":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\nˇ\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n"}}
 {"Key":"v"}
 {"Key":"i"}
 {"Key":"shift-w"}
-{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\nˇ\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n«\nˇ»\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
 {"Put":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\nˇ\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n"}}
 {"Key":"v"}
 {"Key":"i"}
 {"Key":"shift-w"}
-{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\nˇ\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n«\nˇ»The-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
 {"Put":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThˇe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n"}}
 {"Key":"v"}
 {"Key":"i"}
 {"Key":"shift-w"}
-{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\n«The-quicˇ»k brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\n«The-quickˇ» brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
 {"Put":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nTheˇ-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n"}}
 {"Key":"v"}
 {"Key":"i"}
 {"Key":"shift-w"}
-{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\n«The-quicˇ»k brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\n«The-quickˇ» brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
 {"Put":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-ˇquick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n"}}
 {"Key":"v"}
 {"Key":"i"}
 {"Key":"shift-w"}
-{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\n«The-quicˇ»k brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\n«The-quickˇ» brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
 {"Put":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quˇick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n"}}
 {"Key":"v"}
 {"Key":"i"}
 {"Key":"shift-w"}
-{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\n«The-quicˇ»k brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\n«The-quickˇ» brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
 {"Put":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quickˇ brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n"}}
 {"Key":"v"}
 {"Key":"i"}
 {"Key":"shift-w"}
-{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quickˇ brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick« ˇ»brown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
 {"Put":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick ˇbrown \n  \n  \n  fox-jumps over\nthe lazy dog \n\n"}}
 {"Key":"v"}
 {"Key":"i"}
 {"Key":"shift-w"}
-{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick «browˇ»n \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick «brownˇ» \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
 {"Put":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brownˇ \n  \n  \n  fox-jumps over\nthe lazy dog \n\n"}}
 {"Key":"v"}
 {"Key":"i"}
 {"Key":"shift-w"}
-{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brownˇ \n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brown« ˇ»\n  \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
 {"Put":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brown \nˇ  \n  \n  fox-jumps over\nthe lazy dog \n\n"}}
 {"Key":"v"}
 {"Key":"i"}
 {"Key":"shift-w"}
-{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brown \n« ˇ» \n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brown \n«  ˇ»\n  \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
 {"Put":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \nˇ  \n  fox-jumps over\nthe lazy dog \n\n"}}
 {"Key":"v"}
 {"Key":"i"}
 {"Key":"shift-w"}
-{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n« ˇ» \n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n«  ˇ»\n  fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
 {"Put":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \nˇ  fox-jumps over\nthe lazy dog \n\n"}}
 {"Key":"v"}
 {"Key":"i"}
 {"Key":"shift-w"}
-{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \n« ˇ» fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \n«  ˇ»fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
 {"Put":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \n  fox-jumpˇs over\nthe lazy dog \n\n"}}
 {"Key":"v"}
 {"Key":"i"}
 {"Key":"shift-w"}
-{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \n  «fox-jumpˇ»s over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \n  «fox-jumpsˇ» over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
 {"Put":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dogˇ \n\n"}}
 {"Key":"v"}
 {"Key":"i"}
 {"Key":"shift-w"}
-{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dogˇ \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog« ˇ»\n\n","mode":{"Visual":{"line":false}}}}
 {"Put":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \nˇ\n"}}
 {"Key":"v"}
 {"Key":"i"}
 {"Key":"shift-w"}
-{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \nˇ\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown   \nfox jumps over\nthe lazy dog  \n\n\n\nThe-quick brown \n  \n  \n  fox-jumps over\nthe lazy dog \n«\nˇ»","mode":{"Visual":{"line":false}}}}

crates/workspace/src/dock.rs 🔗

@@ -497,9 +497,8 @@ impl View for PanelButtons {
                     };
                     Stack::new()
                         .with_child(
-                            MouseEventHandler::<Self, _>::new(panel_ix, cx, |state, cx| {
+                            MouseEventHandler::new::<Self, _>(panel_ix, cx, |state, cx| {
                                 let style = button_style.in_state(is_active);
-
                                 let style = style.style_for(state);
                                 Flex::row()
                                     .with_child(

crates/workspace/src/notifications.rs 🔗

@@ -290,7 +290,7 @@ pub mod simple_message_notification {
                                 .flex(1., true),
                         )
                         .with_child(
-                            MouseEventHandler::<Cancel, _>::new(0, cx, |state, _| {
+                            MouseEventHandler::new::<Cancel, _>(0, cx, |state, _| {
                                 let style = theme.dismiss_button.style_for(state);
                                 Svg::new("icons/x_mark_8.svg")
                                     .with_color(style.color)
@@ -319,7 +319,7 @@ pub mod simple_message_notification {
                 .with_children({
                     click_message
                         .map(|click_message| {
-                            MouseEventHandler::<MessageNotificationTag, _>::new(
+                            MouseEventHandler::new::<MessageNotificationTag, _>(
                                 0,
                                 cx,
                                 |state, _| {

crates/workspace/src/pane.rs 🔗

@@ -234,7 +234,7 @@ fn nav_button<A: Action, F: 'static + Fn(&mut Pane, &mut ViewContext<Pane>)>(
     action_name: &str,
     cx: &mut ViewContext<Pane>,
 ) -> AnyElement<Pane> {
-    MouseEventHandler::<A, _>::new(0, cx, |state, _| {
+    MouseEventHandler::new::<A, _>(0, cx, |state, _| {
         let style = if enabled {
             style.style_for(state)
         } else {
@@ -1317,7 +1317,7 @@ impl Pane {
 
                             enum Tab {}
                             let mouse_event_handler =
-                                MouseEventHandler::<Tab, Pane>::new(ix, cx, |_, cx| {
+                                MouseEventHandler::new::<Tab, _>(ix, cx, |_, cx| {
                                     Self::render_tab(
                                         &item,
                                         pane.clone(),
@@ -1526,7 +1526,7 @@ impl Pane {
             let item_id = item.id();
             enum TabCloseButton {}
             let icon = Svg::new("icons/x_mark_8.svg");
-            MouseEventHandler::<TabCloseButton, _>::new(item_id, cx, |mouse_state, _| {
+            MouseEventHandler::new::<TabCloseButton, _>(item_id, cx, |mouse_state, _| {
                 if mouse_state.hovered() {
                     icon.with_color(tab_style.icon_close_active)
                 } else {
@@ -1591,7 +1591,7 @@ impl Pane {
     ) -> AnyElement<Pane> {
         enum TabBarButton {}
 
-        let mut button = MouseEventHandler::<TabBarButton, _>::new(index, cx, |mouse_state, cx| {
+        let mut button = MouseEventHandler::new::<TabBarButton, _>(index, cx, |mouse_state, cx| {
             let theme = &settings::get::<ThemeSettings>(cx).theme.workspace.tab_bar;
             let style = theme.pane_button.in_state(is_active).style_for(mouse_state);
             Svg::new(icon)
@@ -1653,7 +1653,7 @@ impl View for Pane {
     fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
         enum MouseNavigationHandler {}
 
-        MouseEventHandler::<MouseNavigationHandler, _>::new(0, cx, |_, cx| {
+        MouseEventHandler::new::<MouseNavigationHandler, _>(0, cx, |_, cx| {
             let active_item_index = self.active_item_index;
 
             if let Some(active_item) = self.active_item() {
@@ -1665,7 +1665,7 @@ impl View for Pane {
 
                         enum TabBarEventHandler {}
                         stack.add_child(
-                            MouseEventHandler::<TabBarEventHandler, _>::new(0, cx, |_, _| {
+                            MouseEventHandler::new::<TabBarEventHandler, _>(0, cx, |_, _| {
                                 Empty::new()
                                     .contained()
                                     .with_style(theme.workspace.tab_bar.container)

crates/workspace/src/pane/dragged_item_receiver.rs 🔗

@@ -19,7 +19,7 @@ pub fn dragged_item_receiver<Tag, D, F>(
     split_margin: Option<f32>,
     cx: &mut ViewContext<Pane>,
     render_child: F,
-) -> MouseEventHandler<Tag, Pane>
+) -> MouseEventHandler<Pane>
 where
     Tag: 'static,
     D: Element<Pane>,
@@ -39,7 +39,7 @@ where
         None
     };
 
-    let mut handler = MouseEventHandler::<Tag, _>::above(region_id, cx, |state, cx| {
+    let mut handler = MouseEventHandler::above::<Tag, _>(region_id, cx, |state, cx| {
         // Observing hovered will cause a render when the mouse enters regardless
         // of if mouse position was accessed before
         let drag_position = if state.hovered() { drag_position } else { None };

crates/workspace/src/pane_group.rs 🔗

@@ -212,7 +212,7 @@ impl Member {
                                 let leader_user_id = leader.user.id;
                                 let app_state = Arc::downgrade(app_state);
                                 Some(
-                                    MouseEventHandler::<FollowIntoExternalProject, _>::new(
+                                    MouseEventHandler::new::<FollowIntoExternalProject, _>(
                                         pane.id(),
                                         cx,
                                         |_, _| {

crates/workspace/src/shared_screen.rs 🔗

@@ -72,7 +72,7 @@ impl View for SharedScreen {
         enum Focus {}
 
         let frame = self.frame.clone();
-        MouseEventHandler::<Focus, _>::new(0, cx, |_, cx| {
+        MouseEventHandler::new::<Focus, _>(0, cx, |_, cx| {
             Canvas::new(move |scene, bounds, _, _, _| {
                 if let Some(frame) = frame.clone() {
                     let size = constrain_size_preserving_aspect_ratio(

crates/workspace/src/toolbar.rs 🔗

@@ -143,6 +143,61 @@ impl View for Toolbar {
     }
 }
 
+// <<<<<<< HEAD
+// =======
+// #[allow(clippy::too_many_arguments)]
+// fn nav_button<A: Action, F: 'static + Fn(&mut Toolbar, &mut ViewContext<Toolbar>)>(
+//     svg_path: &'static str,
+//     style: theme::Interactive<theme::IconButton>,
+//     nav_button_height: f32,
+//     tooltip_style: TooltipStyle,
+//     enabled: bool,
+//     spacing: f32,
+//     on_click: F,
+//     tooltip_action: A,
+//     action_name: &'static str,
+//     cx: &mut ViewContext<Toolbar>,
+// ) -> AnyElement<Toolbar> {
+//     MouseEventHandler::new::<A, _>(0, cx, |state, _| {
+//         let style = if enabled {
+//             style.style_for(state)
+//         } else {
+//             style.disabled_style()
+//         };
+//         Svg::new(svg_path)
+//             .with_color(style.color)
+//             .constrained()
+//             .with_width(style.icon_width)
+//             .aligned()
+//             .contained()
+//             .with_style(style.container)
+//             .constrained()
+//             .with_width(style.button_width)
+//             .with_height(nav_button_height)
+//             .aligned()
+//             .top()
+//     })
+//     .with_cursor_style(if enabled {
+//         CursorStyle::PointingHand
+//     } else {
+//         CursorStyle::default()
+//     })
+//     .on_click(MouseButton::Left, move |_, toolbar, cx| {
+//         on_click(toolbar, cx)
+//     })
+//     .with_tooltip::<A>(
+//         0,
+//         action_name,
+//         Some(Box::new(tooltip_action)),
+//         tooltip_style,
+//         cx,
+//     )
+//     .contained()
+//     .with_margin_right(spacing)
+//     .into_any_named("nav button")
+// }
+
+// >>>>>>> 139cbbfd3aebd0863a7d51b0c12d748764cf0b2e
 impl Toolbar {
     pub fn new() -> Self {
         Self {

crates/workspace/src/workspace.rs 🔗

@@ -2560,7 +2560,7 @@ impl Workspace {
         };
 
         enum TitleBar {}
-        MouseEventHandler::<TitleBar, _>::new(0, cx, |_, cx| {
+        MouseEventHandler::new::<TitleBar, _>(0, cx, |_, cx| {
             Stack::new()
                 .with_children(
                     self.titlebar_item
@@ -2649,7 +2649,7 @@ impl Workspace {
         if self.project.read(cx).is_read_only() {
             enum DisconnectedOverlay {}
             Some(
-                MouseEventHandler::<DisconnectedOverlay, _>::new(0, cx, |_, cx| {
+                MouseEventHandler::new::<DisconnectedOverlay, _>(0, cx, |_, cx| {
                     let theme = &theme::current(cx);
                     Label::new(
                         "Your connection to the remote project has been lost.",

crates/zed/src/languages/cpp/config.toml 🔗

@@ -1,5 +1,5 @@
 name = "C++"
-path_suffixes = ["cc", "cpp", "h", "hpp"]
+path_suffixes = ["cc", "cpp", "h", "hpp", "cxx", "hxx", "inl"]
 line_comment = "// "
 autoclose_before = ";:.,=}])>"
 brackets = [

crates/zed/src/languages/javascript/config.toml 🔗

@@ -1,5 +1,5 @@
 name = "JavaScript"
-path_suffixes = ["js", "jsx", "mjs"]
+path_suffixes = ["js", "jsx", "mjs", "cjs"]
 first_line_pattern = '^#!.*\bnode\b'
 line_comment = "// "
 autoclose_before = ";:.,=}])>"

crates/zed/src/languages/python/config.toml 🔗

@@ -1,5 +1,5 @@
 name = "Python"
-path_suffixes = ["py", "pyi"]
+path_suffixes = ["py", "pyi", "mpy"]
 first_line_pattern = '^#!.*\bpython[0-9.]*\b'
 line_comment = "# "
 autoclose_before = ";:.,=}])>"