Merge pull request #1081 from zed-industries/project-panel-with-new-mouse-events

Antonio Scandurra created

Introduce context menu to project panel

Change summary

Cargo.lock                                      |  27 +
assets/keymaps/default.json                     |   4 
crates/auto_update/src/auto_update.rs           |   2 
crates/chat_panel/Cargo.toml                    |   1 
crates/chat_panel/src/chat_panel.rs             |   8 
crates/collab/src/integration_tests.rs          | 117 +++++
crates/collab/src/main.rs                       |   1 
crates/collab/src/rpc.rs                        |   1 
crates/command_palette/src/command_palette.rs   |   6 
crates/contacts_panel/Cargo.toml                |   1 
crates/contacts_panel/src/contact_finder.rs     |   6 
crates/contacts_panel/src/contacts_panel.rs     |  52 +-
crates/contacts_panel/src/notifications.rs      |  15 
crates/context_menu/Cargo.toml                  |  15 
crates/context_menu/src/context_menu.rs         | 332 +++++++++++++++++
crates/diagnostics/src/items.rs                 |   4 
crates/editor/src/editor.rs                     | 135 +++---
crates/editor/src/element.rs                    |  20 
crates/file_finder/Cargo.toml                   |   1 
crates/file_finder/src/file_finder.rs           |  12 
crates/go_to_line/Cargo.toml                    |   3 
crates/go_to_line/src/go_to_line.rs             |   6 
crates/gpui/src/app.rs                          | 359 +++++++++++++-----
crates/gpui/src/elements.rs                     |   5 
crates/gpui/src/elements/constrained_box.rs     | 112 ++++
crates/gpui/src/elements/container.rs           |   7 
crates/gpui/src/elements/flex.rs                |  10 
crates/gpui/src/elements/keystroke_label.rs     |  95 +++++
crates/gpui/src/elements/list.rs                | 146 +++++--
crates/gpui/src/elements/mouse_event_handler.rs | 196 ++++-----
crates/gpui/src/elements/overlay.rs             |  39 +
crates/gpui/src/elements/uniform_list.rs        |  92 ++-
crates/gpui/src/gpui.rs                         |   2 
crates/gpui/src/keymap.rs                       |  58 ++
crates/gpui/src/platform/event.rs               |   3 
crates/gpui/src/platform/mac/event.rs           |   1 
crates/gpui/src/presenter.rs                    | 348 ++++++++++++++++-
crates/gpui/src/scene.rs                        | 100 ++++
crates/gpui/src/views/select.rs                 |  14 
crates/menu/Cargo.toml                          |  11 
crates/menu/src/menu.rs                         |   0 
crates/outline/src/outline.rs                   |   6 
crates/picker/Cargo.toml                        |   1 
crates/picker/src/picker.rs                     |  24 
crates/project/src/fs.rs                        |  70 +++
crates/project/src/project.rs                   |  72 +++
crates/project/src/worktree.rs                  |  59 +++
crates/project_panel/Cargo.toml                 |   2 
crates/project_panel/src/project_panel.rs       | 309 ++++++++++++++--
crates/project_symbols/src/project_symbols.rs   |   6 
crates/rpc/proto/zed.proto                      | 127 +++---
crates/rpc/src/proto.rs                         |   5 
crates/rpc/src/rpc.rs                           |   2 
crates/search/Cargo.toml                        |   1 
crates/search/src/buffer_search.rs              |   4 
crates/search/src/project_search.rs             |   8 
crates/theme/src/theme.rs                       |  24 +
crates/theme_selector/src/theme_selector.rs     |   4 
crates/workspace/src/lsp_status.rs              |   3 
crates/workspace/src/pane.rs                    |  12 
crates/workspace/src/sidebar.rs                 |   3 
crates/workspace/src/workspace.rs               |  30 +
crates/zed/Cargo.toml                           |   1 
crates/zed/src/main.rs                          |   1 
styles/src/styleTree/app.ts                     |   2 
styles/src/styleTree/contextMenu.ts             |  36 +
styles/src/styleTree/projectPanel.ts            |   1 
67 files changed, 2,500 insertions(+), 680 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -665,6 +665,7 @@ dependencies = [
  "client",
  "editor",
  "gpui",
+ "menu",
  "postage",
  "settings",
  "theme",
@@ -961,6 +962,7 @@ dependencies = [
  "gpui",
  "language",
  "log",
+ "menu",
  "picker",
  "postage",
  "project",
@@ -971,6 +973,17 @@ dependencies = [
  "workspace",
 ]
 
+[[package]]
+name = "context_menu"
+version = "0.1.0"
+dependencies = [
+ "gpui",
+ "menu",
+ "settings",
+ "smallvec",
+ "theme",
+]
+
 [[package]]
 name = "core-foundation"
 version = "0.9.3"
@@ -1513,6 +1526,7 @@ dependencies = [
  "env_logger",
  "fuzzy",
  "gpui",
+ "menu",
  "picker",
  "postage",
  "project",
@@ -1885,6 +1899,7 @@ version = "0.1.0"
 dependencies = [
  "editor",
  "gpui",
+ "menu",
  "postage",
  "settings",
  "text",
@@ -2679,6 +2694,13 @@ dependencies = [
  "autocfg 1.0.1",
 ]
 
+[[package]]
+name = "menu"
+version = "0.1.0"
+dependencies = [
+ "gpui",
+]
+
 [[package]]
 name = "metal"
 version = "0.21.0"
@@ -3184,6 +3206,7 @@ dependencies = [
  "editor",
  "env_logger",
  "gpui",
+ "menu",
  "serde_json",
  "settings",
  "theme",
@@ -3391,9 +3414,11 @@ dependencies = [
 name = "project_panel"
 version = "0.1.0"
 dependencies = [
+ "context_menu",
  "editor",
  "futures",
  "gpui",
+ "menu",
  "postage",
  "project",
  "serde_json",
@@ -4065,6 +4090,7 @@ dependencies = [
  "gpui",
  "language",
  "log",
+ "menu",
  "postage",
  "project",
  "serde",
@@ -5950,6 +5976,7 @@ dependencies = [
  "collections",
  "command_palette",
  "contacts_panel",
+ "context_menu",
  "ctor",
  "diagnostics",
  "dirs 3.0.1",

assets/keymaps/default.json 🔗

@@ -353,6 +353,10 @@
         "bindings": {
             "left": "project_panel::CollapseSelectedEntry",
             "right": "project_panel::ExpandSelectedEntry",
+            "cmd-x": "project_panel::Cut",
+            "cmd-c": "project_panel::Copy",
+            "cmd-v": "project_panel::Paste",
+            "cmd-alt-c": "project_panel::CopyPath",
             "f2": "project_panel::Rename",
             "backspace": "project_panel::Delete"
         }

crates/auto_update/src/auto_update.rs 🔗

@@ -270,7 +270,7 @@ impl View for AutoUpdateIndicator {
                         )
                         .boxed()
                     })
-                    .on_click(|_, cx| cx.dispatch_action(DismissErrorMessage))
+                    .on_click(|_, _, cx| cx.dispatch_action(DismissErrorMessage))
                     .boxed()
                 }
                 AutoUpdateStatus::Idle => Empty::new().boxed(),

crates/chat_panel/Cargo.toml 🔗

@@ -11,6 +11,7 @@ doctest = false
 client = { path = "../client" }
 editor = { path = "../editor" }
 gpui = { path = "../gpui" }
+menu = { path = "../menu" }
 settings = { path = "../settings" }
 theme = { path = "../theme" }
 util = { path = "../util" }

crates/chat_panel/src/chat_panel.rs 🔗

@@ -11,12 +11,12 @@ use gpui::{
     AppContext, Entity, ModelHandle, MutableAppContext, RenderContext, Subscription, Task, View,
     ViewContext, ViewHandle,
 };
+use menu::Confirm;
 use postage::prelude::Stream;
 use settings::{Settings, SoftWrap};
 use std::sync::Arc;
 use time::{OffsetDateTime, UtcOffset};
 use util::{ResultExt, TryFutureExt};
-use workspace::menu::Confirm;
 
 const MESSAGE_LOADING_THRESHOLD: usize = 50;
 
@@ -75,9 +75,9 @@ impl ChatPanel {
             })
         });
 
-        let mut message_list = ListState::new(0, Orientation::Bottom, 1000., {
+        let mut message_list = ListState::new(0, Orientation::Bottom, 1000., cx, {
             let this = cx.weak_handle();
-            move |ix, cx| {
+            move |_, ix, cx| {
                 let this = this.upgrade(cx).unwrap().read(cx);
                 let message = this.active_channel.as_ref().unwrap().0.read(cx).message(ix);
                 this.render_message(message, cx)
@@ -320,7 +320,7 @@ impl ChatPanel {
                 .boxed()
             })
             .with_cursor_style(CursorStyle::PointingHand)
-            .on_click(move |_, cx| {
+            .on_click(move |_, _, cx| {
                 let rpc = rpc.clone();
                 let this = this.clone();
                 cx.spawn(|mut cx| async move {

crates/collab/src/integration_tests.rs 🔗

@@ -656,6 +656,9 @@ async fn test_fs_operations(
     cx_b: &mut TestAppContext,
 ) {
     executor.forbid_parking();
+    let fs = FakeFs::new(cx_a.background());
+
+    // Connect to a server as 2 clients.
     let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await;
     let mut client_a = server.create_client(cx_a, "user_a").await;
     let mut client_b = server.create_client(cx_b, "user_b").await;
@@ -663,7 +666,7 @@ async fn test_fs_operations(
         .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)])
         .await;
 
-    let fs = FakeFs::new(cx_a.background());
+    // Share a project as client A
     fs.insert_tree(
         "/dir",
         json!({
@@ -759,6 +762,110 @@ async fn test_fs_operations(
         );
     });
 
+    project_b
+        .update(cx_b, |project, cx| {
+            project
+                .create_entry((worktree_id, "DIR/e.txt"), false, cx)
+                .unwrap()
+        })
+        .await
+        .unwrap();
+    project_b
+        .update(cx_b, |project, cx| {
+            project
+                .create_entry((worktree_id, "DIR/SUBDIR"), true, cx)
+                .unwrap()
+        })
+        .await
+        .unwrap();
+    project_b
+        .update(cx_b, |project, cx| {
+            project
+                .create_entry((worktree_id, "DIR/SUBDIR/f.txt"), false, cx)
+                .unwrap()
+        })
+        .await
+        .unwrap();
+    worktree_a.read_with(cx_a, |worktree, _| {
+        assert_eq!(
+            worktree
+                .paths()
+                .map(|p| p.to_string_lossy())
+                .collect::<Vec<_>>(),
+            [
+                "DIR",
+                "DIR/SUBDIR",
+                "DIR/SUBDIR/f.txt",
+                "DIR/e.txt",
+                "a.txt",
+                "b.txt",
+                "d.txt"
+            ]
+        );
+    });
+    worktree_b.read_with(cx_b, |worktree, _| {
+        assert_eq!(
+            worktree
+                .paths()
+                .map(|p| p.to_string_lossy())
+                .collect::<Vec<_>>(),
+            [
+                "DIR",
+                "DIR/SUBDIR",
+                "DIR/SUBDIR/f.txt",
+                "DIR/e.txt",
+                "a.txt",
+                "b.txt",
+                "d.txt"
+            ]
+        );
+    });
+
+    project_b
+        .update(cx_b, |project, cx| {
+            project
+                .copy_entry(entry.id, Path::new("f.txt"), cx)
+                .unwrap()
+        })
+        .await
+        .unwrap();
+    worktree_a.read_with(cx_a, |worktree, _| {
+        assert_eq!(
+            worktree
+                .paths()
+                .map(|p| p.to_string_lossy())
+                .collect::<Vec<_>>(),
+            [
+                "DIR",
+                "DIR/SUBDIR",
+                "DIR/SUBDIR/f.txt",
+                "DIR/e.txt",
+                "a.txt",
+                "b.txt",
+                "d.txt",
+                "f.txt"
+            ]
+        );
+    });
+    worktree_b.read_with(cx_b, |worktree, _| {
+        assert_eq!(
+            worktree
+                .paths()
+                .map(|p| p.to_string_lossy())
+                .collect::<Vec<_>>(),
+            [
+                "DIR",
+                "DIR/SUBDIR",
+                "DIR/SUBDIR/f.txt",
+                "DIR/e.txt",
+                "a.txt",
+                "b.txt",
+                "d.txt",
+                "f.txt"
+            ]
+        );
+    });
+
     project_b
         .update(cx_b, |project, cx| {
             project.delete_entry(dir_entry.id, cx).unwrap()
@@ -771,7 +878,7 @@ async fn test_fs_operations(
                 .paths()
                 .map(|p| p.to_string_lossy())
                 .collect::<Vec<_>>(),
-            ["a.txt", "b.txt", "d.txt"]
+            ["a.txt", "b.txt", "d.txt", "f.txt"]
         );
     });
     worktree_b.read_with(cx_b, |worktree, _| {
@@ -780,7 +887,7 @@ async fn test_fs_operations(
                 .paths()
                 .map(|p| p.to_string_lossy())
                 .collect::<Vec<_>>(),
-            ["a.txt", "b.txt", "d.txt"]
+            ["a.txt", "b.txt", "d.txt", "f.txt"]
         );
     });
 
@@ -796,7 +903,7 @@ async fn test_fs_operations(
                 .paths()
                 .map(|p| p.to_string_lossy())
                 .collect::<Vec<_>>(),
-            ["a.txt", "b.txt"]
+            ["a.txt", "b.txt", "f.txt"]
         );
     });
     worktree_b.read_with(cx_b, |worktree, _| {
@@ -805,7 +912,7 @@ async fn test_fs_operations(
                 .paths()
                 .map(|p| p.to_string_lossy())
                 .collect::<Vec<_>>(),
-            ["a.txt", "b.txt"]
+            ["a.txt", "b.txt", "f.txt"]
         );
     });
 }

crates/collab/src/main.rs 🔗

@@ -82,6 +82,7 @@ pub fn init_tracing(config: &Config) -> Option<()> {
     use tracing_subscriber::layer::SubscriberExt;
     let rust_log = config.rust_log.clone()?;
 
+    println!("HEY!");
     LogTracer::init().log_err()?;
 
     let subscriber = tracing_subscriber::Registry::default()

crates/collab/src/rpc.rs 🔗

@@ -171,6 +171,7 @@ impl Server {
             .add_request_handler(Server::forward_project_request::<proto::FormatBuffers>)
             .add_request_handler(Server::forward_project_request::<proto::CreateProjectEntry>)
             .add_request_handler(Server::forward_project_request::<proto::RenameProjectEntry>)
+            .add_request_handler(Server::forward_project_request::<proto::CopyProjectEntry>)
             .add_request_handler(Server::forward_project_request::<proto::DeleteProjectEntry>)
             .add_request_handler(Server::update_buffer)
             .add_message_handler(Server::update_buffer_file)

crates/command_palette/src/command_palette.rs 🔗

@@ -1,9 +1,9 @@
 use fuzzy::{StringMatch, StringMatchCandidate};
 use gpui::{
     actions,
-    elements::{ChildView, Flex, Label, MouseState, ParentElement},
+    elements::{ChildView, Flex, Label, ParentElement},
     keymap::Keystroke,
-    Action, Element, Entity, MutableAppContext, View, ViewContext, ViewHandle,
+    Action, Element, Entity, MouseState, MutableAppContext, View, ViewContext, ViewHandle,
 };
 use picker::{Picker, PickerDelegate};
 use settings::Settings;
@@ -203,7 +203,7 @@ impl PickerDelegate for CommandPalette {
     fn render_match(
         &self,
         ix: usize,
-        mouse_state: &MouseState,
+        mouse_state: MouseState,
         selected: bool,
         cx: &gpui::AppContext,
     ) -> gpui::ElementBox {

crates/contacts_panel/Cargo.toml 🔗

@@ -12,6 +12,7 @@ client = { path = "../client" }
 editor = { path = "../editor" }
 fuzzy = { path = "../fuzzy" }
 gpui = { path = "../gpui" }
+menu = { path = "../menu" }
 picker = { path = "../picker" }
 project = { path = "../project" }
 settings = { path = "../settings" }

crates/contacts_panel/src/contact_finder.rs 🔗

@@ -1,7 +1,7 @@
 use client::{ContactRequestStatus, User, UserStore};
 use gpui::{
-    actions, elements::*, Entity, ModelHandle, MutableAppContext, RenderContext, Task, View,
-    ViewContext, ViewHandle,
+    actions, elements::*, Entity, ModelHandle, MouseState, MutableAppContext, RenderContext, Task,
+    View, ViewContext, ViewHandle,
 };
 use picker::{Picker, PickerDelegate};
 use settings::Settings;
@@ -105,7 +105,7 @@ impl PickerDelegate for ContactFinder {
     fn render_match(
         &self,
         ix: usize,
-        mouse_state: &MouseState,
+        mouse_state: MouseState,
         selected: bool,
         cx: &gpui::AppContext,
     ) -> ElementBox {

crates/contacts_panel/src/contacts_panel.rs 🔗

@@ -12,19 +12,16 @@ use gpui::{
     geometry::{rect::RectF, vector::vec2f},
     impl_actions, impl_internal_actions,
     platform::CursorStyle,
-    AppContext, ClipboardItem, Element, ElementBox, Entity, LayoutContext, ModelHandle,
-    MutableAppContext, RenderContext, Subscription, View, ViewContext, ViewHandle, WeakViewHandle,
+    AppContext, ClipboardItem, Element, ElementBox, Entity, ModelHandle, MutableAppContext,
+    RenderContext, Subscription, View, ViewContext, ViewHandle, WeakViewHandle,
 };
 use join_project_notification::JoinProjectNotification;
+use menu::{Confirm, SelectNext, SelectPrev};
 use serde::Deserialize;
 use settings::Settings;
 use std::sync::Arc;
 use theme::IconButton;
-use workspace::{
-    menu::{Confirm, SelectNext, SelectPrev},
-    sidebar::SidebarItem,
-    JoinProject, Workspace,
-};
+use workspace::{sidebar::SidebarItem, JoinProject, Workspace};
 
 impl_actions!(
     contacts_panel,
@@ -184,11 +181,8 @@ impl ContactsPanel {
         .detach();
 
         let mut this = Self {
-            list_state: ListState::new(0, Orientation::Top, 1000., {
-                let this = cx.weak_handle();
-                move |ix, cx| {
-                    let this = this.upgrade(cx).unwrap();
-                    let this = this.read(cx);
+            list_state: ListState::new(0, Orientation::Top, 1000., cx, {
+                move |this, ix, cx| {
                     let theme = cx.global::<Settings>().theme.clone();
                     let theme = &theme.contacts_panel;
                     let current_user_id =
@@ -258,11 +252,11 @@ impl ContactsPanel {
         theme: &theme::ContactsPanel,
         is_selected: bool,
         is_collapsed: bool,
-        cx: &mut LayoutContext,
+        cx: &mut RenderContext<Self>,
     ) -> ElementBox {
         enum Header {}
 
-        let header_style = theme.header_row.style_for(&Default::default(), is_selected);
+        let header_style = theme.header_row.style_for(Default::default(), is_selected);
         let text = match section {
             Section::Requests => "Requests",
             Section::Online => "Online",
@@ -302,7 +296,7 @@ impl ContactsPanel {
                 .boxed()
         })
         .with_cursor_style(CursorStyle::PointingHand)
-        .on_click(move |_, cx| cx.dispatch_action(ToggleExpanded(section)))
+        .on_click(move |_, _, cx| cx.dispatch_action(ToggleExpanded(section)))
         .boxed()
     }
 
@@ -334,11 +328,7 @@ impl ContactsPanel {
             .constrained()
             .with_height(theme.row_height)
             .contained()
-            .with_style(
-                *theme
-                    .contact_row
-                    .style_for(&Default::default(), is_selected),
-            )
+            .with_style(*theme.contact_row.style_for(Default::default(), is_selected))
             .boxed()
     }
 
@@ -349,7 +339,7 @@ impl ContactsPanel {
         theme: &theme::ContactsPanel,
         is_last_project: bool,
         is_selected: bool,
-        cx: &mut LayoutContext,
+        cx: &mut RenderContext<Self>,
     ) -> ElementBox {
         let project = &contact.projects[project_index];
         let project_id = project.id;
@@ -445,7 +435,7 @@ impl ContactsPanel {
         } else {
             CursorStyle::Arrow
         })
-        .on_click(move |_, cx| {
+        .on_click(move |_, _, cx| {
             if !is_host {
                 cx.dispatch_global_action(JoinProject {
                     contact: contact.clone(),
@@ -462,7 +452,7 @@ impl ContactsPanel {
         theme: &theme::ContactsPanel,
         is_incoming: bool,
         is_selected: bool,
-        cx: &mut LayoutContext,
+        cx: &mut RenderContext<ContactsPanel>,
     ) -> ElementBox {
         enum Decline {}
         enum Accept {}
@@ -507,7 +497,7 @@ impl ContactsPanel {
                         .boxed()
                 })
                 .with_cursor_style(CursorStyle::PointingHand)
-                .on_click(move |_, cx| {
+                .on_click(move |_, _, cx| {
                     cx.dispatch_action(RespondToContactRequest {
                         user_id,
                         accept: false,
@@ -529,7 +519,7 @@ impl ContactsPanel {
                         .boxed()
                 })
                 .with_cursor_style(CursorStyle::PointingHand)
-                .on_click(move |_, cx| {
+                .on_click(move |_, _, cx| {
                     cx.dispatch_action(RespondToContactRequest {
                         user_id,
                         accept: true,
@@ -552,7 +542,7 @@ impl ContactsPanel {
                 })
                 .with_padding(Padding::uniform(2.))
                 .with_cursor_style(CursorStyle::PointingHand)
-                .on_click(move |_, cx| cx.dispatch_action(RemoveContact(user_id)))
+                .on_click(move |_, _, cx| cx.dispatch_action(RemoveContact(user_id)))
                 .flex_float()
                 .boxed(),
             );
@@ -561,11 +551,7 @@ impl ContactsPanel {
         row.constrained()
             .with_height(theme.row_height)
             .contained()
-            .with_style(
-                *theme
-                    .contact_row
-                    .style_for(&Default::default(), is_selected),
-            )
+            .with_style(*theme.contact_row.style_for(Default::default(), is_selected))
             .boxed()
     }
 
@@ -865,7 +851,7 @@ impl View for ContactsPanel {
                                     .boxed()
                             })
                             .with_cursor_style(CursorStyle::PointingHand)
-                            .on_click(|_, cx| cx.dispatch_action(contact_finder::Toggle))
+                            .on_click(|_, _, cx| cx.dispatch_action(contact_finder::Toggle))
                             .boxed(),
                         )
                         .constrained()
@@ -913,7 +899,7 @@ impl View for ContactsPanel {
                                         },
                                     )
                                     .with_cursor_style(CursorStyle::PointingHand)
-                                    .on_click(move |_, cx| {
+                                    .on_click(move |_, _, cx| {
                                         cx.write_to_clipboard(ClipboardItem::new(
                                             info.url.to_string(),
                                         ));

crates/contacts_panel/src/notifications.rs 🔗

@@ -61,7 +61,7 @@ pub fn render_user_notification<V: View, A: Action + Clone>(
                     })
                     .with_cursor_style(CursorStyle::PointingHand)
                     .with_padding(Padding::uniform(5.))
-                    .on_click(move |_, cx| cx.dispatch_any_action(dismiss_action.boxed_clone()))
+                    .on_click(move |_, _, cx| cx.dispatch_any_action(dismiss_action.boxed_clone()))
                     .aligned()
                     .constrained()
                     .with_height(
@@ -76,13 +76,10 @@ pub fn render_user_notification<V: View, A: Action + Clone>(
                 .named("contact notification header"),
         )
         .with_children(body.map(|body| {
-            Label::new(
-                body.to_string(),
-                theme.body_message.text.clone(),
-            )
-            .contained()
-            .with_style(theme.body_message.container)
-            .boxed()
+            Label::new(body.to_string(), theme.body_message.text.clone())
+                .contained()
+                .with_style(theme.body_message.container)
+                .boxed()
         }))
         .with_children(if buttons.is_empty() {
             None
@@ -99,7 +96,7 @@ pub fn render_user_notification<V: View, A: Action + Clone>(
                                     .boxed()
                             })
                             .with_cursor_style(CursorStyle::PointingHand)
-                            .on_click(move |_, cx| cx.dispatch_any_action(action.boxed_clone()))
+                            .on_click(move |_, _, cx| cx.dispatch_any_action(action.boxed_clone()))
                             .boxed()
                         },
                     ))

crates/context_menu/Cargo.toml 🔗

@@ -0,0 +1,15 @@
+[package]
+name = "context_menu"
+version = "0.1.0"
+edition = "2021"
+
+[lib]
+path = "src/context_menu.rs"
+doctest = false
+
+[dependencies]
+gpui = { path = "../gpui" }
+menu = { path = "../menu" }
+settings = { path = "../settings" }
+theme = { path = "../theme" }
+smallvec = "1.6"

crates/context_menu/src/context_menu.rs 🔗

@@ -0,0 +1,332 @@
+use std::{any::TypeId, time::Duration};
+
+use gpui::{
+    elements::*, geometry::vector::Vector2F, keymap, platform::CursorStyle, Action, AppContext,
+    Axis, Entity, MutableAppContext, RenderContext, SizeConstraint, Subscription, View,
+    ViewContext,
+};
+use menu::*;
+use settings::Settings;
+
+pub fn init(cx: &mut MutableAppContext) {
+    cx.add_action(ContextMenu::select_first);
+    cx.add_action(ContextMenu::select_last);
+    cx.add_action(ContextMenu::select_next);
+    cx.add_action(ContextMenu::select_prev);
+    cx.add_action(ContextMenu::confirm);
+    cx.add_action(ContextMenu::cancel);
+}
+
+pub enum ContextMenuItem {
+    Item {
+        label: String,
+        action: Box<dyn Action>,
+    },
+    Separator,
+}
+
+impl ContextMenuItem {
+    pub fn item(label: impl ToString, action: impl 'static + Action) -> Self {
+        Self::Item {
+            label: label.to_string(),
+            action: Box::new(action),
+        }
+    }
+
+    pub fn separator() -> Self {
+        Self::Separator
+    }
+
+    fn is_separator(&self) -> bool {
+        matches!(self, Self::Separator)
+    }
+
+    fn action_id(&self) -> Option<TypeId> {
+        match self {
+            ContextMenuItem::Item { action, .. } => Some(action.id()),
+            ContextMenuItem::Separator => None,
+        }
+    }
+}
+
+pub struct ContextMenu {
+    position: Vector2F,
+    items: Vec<ContextMenuItem>,
+    selected_index: Option<usize>,
+    visible: bool,
+    previously_focused_view_id: Option<usize>,
+    _actions_observation: Subscription,
+}
+
+impl Entity for ContextMenu {
+    type Event = ();
+}
+
+impl View for ContextMenu {
+    fn ui_name() -> &'static str {
+        "ContextMenu"
+    }
+
+    fn keymap_context(&self, _: &AppContext) -> keymap::Context {
+        let mut cx = Self::default_keymap_context();
+        cx.set.insert("menu".into());
+        cx
+    }
+
+    fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
+        if !self.visible {
+            return Empty::new().boxed();
+        }
+
+        // Render the menu once at minimum width.
+        let mut collapsed_menu = self.render_menu_for_measurement(cx).boxed();
+        let expanded_menu = self
+            .render_menu(cx)
+            .constrained()
+            .dynamically(move |constraint, cx| {
+                SizeConstraint::strict_along(
+                    Axis::Horizontal,
+                    collapsed_menu.layout(constraint, cx).x(),
+                )
+            })
+            .boxed();
+
+        Overlay::new(expanded_menu)
+            .with_abs_position(self.position)
+            .boxed()
+    }
+
+    fn on_blur(&mut self, cx: &mut ViewContext<Self>) {
+        self.reset(cx);
+    }
+}
+
+impl ContextMenu {
+    pub fn new(cx: &mut ViewContext<Self>) -> Self {
+        Self {
+            position: Default::default(),
+            items: Default::default(),
+            selected_index: Default::default(),
+            visible: Default::default(),
+            previously_focused_view_id: Default::default(),
+            _actions_observation: cx.observe_actions(Self::action_dispatched),
+        }
+    }
+
+    fn action_dispatched(&mut self, action_id: TypeId, cx: &mut ViewContext<Self>) {
+        if let Some(ix) = self
+            .items
+            .iter()
+            .position(|item| item.action_id() == Some(action_id))
+        {
+            self.selected_index = Some(ix);
+            cx.notify();
+            cx.spawn(|this, mut cx| async move {
+                cx.background().timer(Duration::from_millis(100)).await;
+                this.update(&mut cx, |this, cx| this.cancel(&Default::default(), cx));
+            })
+            .detach();
+        }
+    }
+
+    fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
+        if let Some(ix) = self.selected_index {
+            if let Some(ContextMenuItem::Item { action, .. }) = self.items.get(ix) {
+                let window_id = cx.window_id();
+                let view_id = cx.view_id();
+                cx.dispatch_action_at(window_id, view_id, action.as_ref());
+            }
+        }
+    }
+
+    fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
+        self.reset(cx);
+        cx.defer(|this, cx| {
+            if cx.handle().is_focused(cx) {
+                let window_id = cx.window_id();
+                (**cx).focus(window_id, this.previously_focused_view_id.take());
+            }
+        });
+    }
+
+    fn reset(&mut self, cx: &mut ViewContext<Self>) {
+        self.items.clear();
+        self.visible = false;
+        self.selected_index.take();
+        cx.notify();
+    }
+
+    fn select_first(&mut self, _: &SelectFirst, cx: &mut ViewContext<Self>) {
+        self.selected_index = self.items.iter().position(|item| !item.is_separator());
+        cx.notify();
+    }
+
+    fn select_last(&mut self, _: &SelectLast, cx: &mut ViewContext<Self>) {
+        for (ix, item) in self.items.iter().enumerate().rev() {
+            if !item.is_separator() {
+                self.selected_index = Some(ix);
+                cx.notify();
+                break;
+            }
+        }
+    }
+
+    fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
+        if let Some(ix) = self.selected_index {
+            for (ix, item) in self.items.iter().enumerate().skip(ix + 1) {
+                if !item.is_separator() {
+                    self.selected_index = Some(ix);
+                    cx.notify();
+                    break;
+                }
+            }
+        } else {
+            self.select_first(&Default::default(), cx);
+        }
+    }
+
+    fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
+        if let Some(ix) = self.selected_index {
+            for (ix, item) in self.items.iter().enumerate().take(ix).rev() {
+                if !item.is_separator() {
+                    self.selected_index = Some(ix);
+                    cx.notify();
+                    break;
+                }
+            }
+        } else {
+            self.select_last(&Default::default(), cx);
+        }
+    }
+
+    pub fn show(
+        &mut self,
+        position: Vector2F,
+        items: impl IntoIterator<Item = ContextMenuItem>,
+        cx: &mut ViewContext<Self>,
+    ) {
+        let mut items = items.into_iter().peekable();
+        if items.peek().is_some() {
+            self.items = items.collect();
+            self.position = position;
+            self.visible = true;
+            if !cx.is_self_focused() {
+                self.previously_focused_view_id = cx.focused_view_id(cx.window_id());
+            }
+            cx.focus_self();
+        } else {
+            self.visible = false;
+        }
+        cx.notify();
+    }
+
+    fn render_menu_for_measurement(&self, cx: &mut RenderContext<Self>) -> impl Element {
+        let style = cx.global::<Settings>().theme.context_menu.clone();
+        Flex::row()
+            .with_child(
+                Flex::column()
+                    .with_children(self.items.iter().enumerate().map(|(ix, item)| {
+                        match item {
+                            ContextMenuItem::Item { label, .. } => {
+                                let style = style
+                                    .item
+                                    .style_for(Default::default(), Some(ix) == self.selected_index);
+                                Label::new(label.to_string(), style.label.clone())
+                                    .contained()
+                                    .with_style(style.container)
+                                    .boxed()
+                            }
+                            ContextMenuItem::Separator => Empty::new()
+                                .collapsed()
+                                .contained()
+                                .with_style(style.separator)
+                                .constrained()
+                                .with_height(1.)
+                                .boxed(),
+                        }
+                    }))
+                    .boxed(),
+            )
+            .with_child(
+                Flex::column()
+                    .with_children(self.items.iter().enumerate().map(|(ix, item)| {
+                        match item {
+                            ContextMenuItem::Item { action, .. } => {
+                                let style = style
+                                    .item
+                                    .style_for(Default::default(), Some(ix) == self.selected_index);
+                                KeystrokeLabel::new(
+                                    action.boxed_clone(),
+                                    style.keystroke.container,
+                                    style.keystroke.text.clone(),
+                                )
+                                .boxed()
+                            }
+                            ContextMenuItem::Separator => Empty::new()
+                                .collapsed()
+                                .constrained()
+                                .with_height(1.)
+                                .contained()
+                                .with_style(style.separator)
+                                .boxed(),
+                        }
+                    }))
+                    .boxed(),
+            )
+            .contained()
+            .with_style(style.container)
+    }
+
+    fn render_menu(&self, cx: &mut RenderContext<Self>) -> impl Element {
+        enum Menu {}
+        enum MenuItem {}
+        let style = cx.global::<Settings>().theme.context_menu.clone();
+        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.boxed_clone();
+                            MouseEventHandler::new::<MenuItem, _, _>(ix, cx, |state, _| {
+                                let style =
+                                    style.item.style_for(state, Some(ix) == self.selected_index);
+                                Flex::row()
+                                    .with_child(
+                                        Label::new(label.to_string(), style.label.clone()).boxed(),
+                                    )
+                                    .with_child({
+                                        KeystrokeLabel::new(
+                                            action.boxed_clone(),
+                                            style.keystroke.container,
+                                            style.keystroke.text.clone(),
+                                        )
+                                        .flex_float()
+                                        .boxed()
+                                    })
+                                    .contained()
+                                    .with_style(style.container)
+                                    .boxed()
+                            })
+                            .with_cursor_style(CursorStyle::PointingHand)
+                            .on_click(move |_, _, cx| {
+                                cx.dispatch_any_action(action.boxed_clone());
+                                cx.dispatch_action(Cancel);
+                            })
+                            .boxed()
+                        }
+                        ContextMenuItem::Separator => Empty::new()
+                            .constrained()
+                            .with_height(1.)
+                            .contained()
+                            .with_style(style.separator)
+                            .boxed(),
+                    }
+                }))
+                .contained()
+                .with_style(style.container)
+                .boxed()
+        })
+        .on_mouse_down_out(|_, cx| cx.dispatch_action(Cancel))
+        .on_right_mouse_down_out(|_, cx| cx.dispatch_action(Cancel))
+    }
+}

crates/diagnostics/src/items.rs 🔗

@@ -159,7 +159,7 @@ impl View for DiagnosticIndicator {
                     .boxed()
             })
             .with_cursor_style(CursorStyle::PointingHand)
-            .on_click(|_, cx| cx.dispatch_action(crate::Deploy))
+            .on_click(|_, _, cx| cx.dispatch_action(crate::Deploy))
             .aligned()
             .boxed(),
         );
@@ -192,7 +192,7 @@ impl View for DiagnosticIndicator {
                     .boxed()
                 })
                 .with_cursor_style(CursorStyle::PointingHand)
-                .on_click(|_, cx| cx.dispatch_action(GoToNextDiagnostic))
+                .on_click(|_, _, cx| cx.dispatch_action(GoToNextDiagnostic))
                 .boxed(),
             );
         }

crates/editor/src/editor.rs 🔗

@@ -592,11 +592,11 @@ impl ContextMenu {
         &self,
         cursor_position: DisplayPoint,
         style: EditorStyle,
-        cx: &AppContext,
+        cx: &mut RenderContext<Editor>,
     ) -> (DisplayPoint, ElementBox) {
         match self {
             ContextMenu::Completions(menu) => (cursor_position, menu.render(style, cx)),
-            ContextMenu::CodeActions(menu) => menu.render(cursor_position, style),
+            ContextMenu::CodeActions(menu) => menu.render(cursor_position, style, cx),
         }
     }
 }
@@ -633,54 +633,62 @@ impl CompletionsMenu {
         !self.matches.is_empty()
     }
 
-    fn render(&self, style: EditorStyle, _: &AppContext) -> ElementBox {
+    fn render(&self, style: EditorStyle, cx: &mut RenderContext<Editor>) -> ElementBox {
         enum CompletionTag {}
 
         let completions = self.completions.clone();
         let matches = self.matches.clone();
         let selected_item = self.selected_item;
         let container_style = style.autocomplete.container;
-        UniformList::new(self.list.clone(), matches.len(), move |range, items, cx| {
-            let start_ix = range.start;
-            for (ix, mat) in matches[range].iter().enumerate() {
-                let completion = &completions[mat.candidate_id];
-                let item_ix = start_ix + ix;
-                items.push(
-                    MouseEventHandler::new::<CompletionTag, _, _>(
-                        mat.candidate_id,
-                        cx,
-                        |state, _| {
-                            let item_style = if item_ix == selected_item {
-                                style.autocomplete.selected_item
-                            } else if state.hovered {
-                                style.autocomplete.hovered_item
-                            } else {
-                                style.autocomplete.item
-                            };
-
-                            Text::new(completion.label.text.clone(), style.text.clone())
-                                .with_soft_wrap(false)
-                                .with_highlights(combine_syntax_and_fuzzy_match_highlights(
-                                    &completion.label.text,
-                                    style.text.color.into(),
-                                    styled_runs_for_code_label(&completion.label, &style.syntax),
-                                    &mat.positions,
-                                ))
-                                .contained()
-                                .with_style(item_style)
-                                .boxed()
-                        },
-                    )
-                    .with_cursor_style(CursorStyle::PointingHand)
-                    .on_mouse_down(move |cx| {
-                        cx.dispatch_action(ConfirmCompletion {
-                            item_ix: Some(item_ix),
-                        });
-                    })
-                    .boxed(),
-                );
-            }
-        })
+        UniformList::new(
+            self.list.clone(),
+            matches.len(),
+            cx,
+            move |_, range, items, cx| {
+                let start_ix = range.start;
+                for (ix, mat) in matches[range].iter().enumerate() {
+                    let completion = &completions[mat.candidate_id];
+                    let item_ix = start_ix + ix;
+                    items.push(
+                        MouseEventHandler::new::<CompletionTag, _, _>(
+                            mat.candidate_id,
+                            cx,
+                            |state, _| {
+                                let item_style = if item_ix == selected_item {
+                                    style.autocomplete.selected_item
+                                } else if state.hovered {
+                                    style.autocomplete.hovered_item
+                                } else {
+                                    style.autocomplete.item
+                                };
+
+                                Text::new(completion.label.text.clone(), style.text.clone())
+                                    .with_soft_wrap(false)
+                                    .with_highlights(combine_syntax_and_fuzzy_match_highlights(
+                                        &completion.label.text,
+                                        style.text.color.into(),
+                                        styled_runs_for_code_label(
+                                            &completion.label,
+                                            &style.syntax,
+                                        ),
+                                        &mat.positions,
+                                    ))
+                                    .contained()
+                                    .with_style(item_style)
+                                    .boxed()
+                            },
+                        )
+                        .with_cursor_style(CursorStyle::PointingHand)
+                        .on_mouse_down(move |_, cx| {
+                            cx.dispatch_action(ConfirmCompletion {
+                                item_ix: Some(item_ix),
+                            });
+                        })
+                        .boxed(),
+                    );
+                }
+            },
+        )
         .with_width_from_item(
             self.matches
                 .iter()
@@ -772,14 +780,18 @@ impl CodeActionsMenu {
         &self,
         mut cursor_position: DisplayPoint,
         style: EditorStyle,
+        cx: &mut RenderContext<Editor>,
     ) -> (DisplayPoint, ElementBox) {
         enum ActionTag {}
 
         let container_style = style.autocomplete.container;
         let actions = self.actions.clone();
         let selected_item = self.selected_item;
-        let element =
-            UniformList::new(self.list.clone(), actions.len(), move |range, items, cx| {
+        let element = UniformList::new(
+            self.list.clone(),
+            actions.len(),
+            cx,
+            move |_, range, items, cx| {
                 let start_ix = range.start;
                 for (ix, action) in actions[range].iter().enumerate() {
                     let item_ix = start_ix + ix;
@@ -800,7 +812,7 @@ impl CodeActionsMenu {
                                 .boxed()
                         })
                         .with_cursor_style(CursorStyle::PointingHand)
-                        .on_mouse_down(move |cx| {
+                        .on_mouse_down(move |_, cx| {
                             cx.dispatch_action(ConfirmCodeAction {
                                 item_ix: Some(item_ix),
                             });
@@ -808,17 +820,18 @@ impl CodeActionsMenu {
                         .boxed(),
                     );
                 }
-            })
-            .with_width_from_item(
-                self.actions
-                    .iter()
-                    .enumerate()
-                    .max_by_key(|(_, action)| action.lsp_action.title.chars().count())
-                    .map(|(ix, _)| ix),
-            )
-            .contained()
-            .with_style(container_style)
-            .boxed();
+            },
+        )
+        .with_width_from_item(
+            self.actions
+                .iter()
+                .enumerate()
+                .max_by_key(|(_, action)| action.lsp_action.title.chars().count())
+                .map(|(ix, _)| ix),
+        )
+        .contained()
+        .with_style(container_style)
+        .boxed();
 
         if self.deployed_from_indicator {
             *cursor_position.column_mut() = 0;
@@ -2572,7 +2585,7 @@ impl Editor {
     pub fn render_code_actions_indicator(
         &self,
         style: &EditorStyle,
-        cx: &mut ViewContext<Self>,
+        cx: &mut RenderContext<Self>,
     ) -> Option<ElementBox> {
         if self.available_code_actions.is_some() {
             enum Tag {}
@@ -2584,7 +2597,7 @@ impl Editor {
                 })
                 .with_cursor_style(CursorStyle::PointingHand)
                 .with_padding(Padding::uniform(3.))
-                .on_mouse_down(|cx| {
+                .on_mouse_down(|_, cx| {
                     cx.dispatch_action(ToggleCodeActions {
                         deployed_from_indicator: true,
                     });
@@ -2606,7 +2619,7 @@ impl Editor {
         &self,
         cursor_position: DisplayPoint,
         style: EditorStyle,
-        cx: &AppContext,
+        cx: &mut RenderContext<Editor>,
     ) -> Option<(DisplayPoint, ElementBox)> {
         self.context_menu
             .as_ref()

crates/editor/src/element.rs 🔗

@@ -21,8 +21,9 @@ use gpui::{
     json::{self, ToJson},
     platform::CursorStyle,
     text_layout::{self, Line, RunStyle, TextLayoutCache},
-    AppContext, Axis, Border, Element, ElementBox, Event, EventContext, LayoutContext,
-    MutableAppContext, PaintContext, Quad, Scene, SizeConstraint, ViewContext, WeakViewHandle,
+    AppContext, Axis, Border, CursorRegion, Element, ElementBox, Event, EventContext,
+    LayoutContext, MutableAppContext, PaintContext, Quad, Scene, SizeConstraint, ViewContext,
+    WeakViewHandle,
 };
 use json::json;
 use language::{Bias, DiagnosticSeverity, Selection};
@@ -362,7 +363,10 @@ impl EditorElement {
         let content_origin = bounds.origin() + vec2f(layout.gutter_margin, 0.);
 
         cx.scene.push_layer(Some(bounds));
-        cx.scene.push_cursor_style(bounds, CursorStyle::IBeam);
+        cx.scene.push_cursor_region(CursorRegion {
+            bounds,
+            style: CursorStyle::IBeam,
+        });
 
         for (range, color) in &layout.highlighted_ranges {
             self.paint_highlighted_range(
@@ -1041,8 +1045,6 @@ impl Element for EditorElement {
             max_row.saturating_sub(1) as f32,
         );
 
-        let mut context_menu = None;
-        let mut code_actions_indicator = None;
         self.update_view(cx.app, |view, cx| {
             let clamped = view.clamp_scroll_left(scroll_max.x());
             let autoscrolled;
@@ -1062,7 +1064,11 @@ impl Element for EditorElement {
             if clamped || autoscrolled {
                 snapshot = view.snapshot(cx);
             }
+        });
 
+        let mut context_menu = None;
+        let mut code_actions_indicator = None;
+        cx.render(&self.view.upgrade(cx).unwrap(), |view, cx| {
             let newest_selection_head = view
                 .selections
                 .newest::<usize>(cx)
@@ -1549,7 +1555,7 @@ mod tests {
         let layouts = editor.update(cx, |editor, cx| {
             let snapshot = editor.snapshot(cx);
             let mut presenter = cx.build_presenter(window_id, 30.);
-            let mut layout_cx = presenter.build_layout_context(false, cx);
+            let mut layout_cx = presenter.build_layout_context(Vector2F::zero(), false, cx);
             element.layout_line_numbers(0..6, &Default::default(), &snapshot, &mut layout_cx)
         });
         assert_eq!(layouts.len(), 6);
@@ -1587,7 +1593,7 @@ mod tests {
 
         let mut scene = Scene::new(1.0);
         let mut presenter = cx.build_presenter(window_id, 30.);
-        let mut layout_cx = presenter.build_layout_context(false, cx);
+        let mut layout_cx = presenter.build_layout_context(Vector2F::zero(), false, cx);
         let (size, mut state) = element.layout(
             SizeConstraint::new(vec2f(500., 500.), vec2f(500., 500.)),
             &mut layout_cx,

crates/file_finder/Cargo.toml 🔗

@@ -11,6 +11,7 @@ doctest = false
 editor = { path = "../editor" }
 fuzzy = { path = "../fuzzy" }
 gpui = { path = "../gpui" }
+menu = { path = "../menu" }
 picker = { path = "../picker" }
 project = { path = "../project" }
 settings = { path = "../settings" }

crates/file_finder/src/file_finder.rs 🔗

@@ -1,7 +1,7 @@
 use fuzzy::PathMatch;
 use gpui::{
-    actions, elements::*, AppContext, Entity, ModelHandle, MutableAppContext, RenderContext, Task,
-    View, ViewContext, ViewHandle,
+    actions, elements::*, AppContext, Entity, ModelHandle, MouseState, MutableAppContext,
+    RenderContext, Task, View, ViewContext, ViewHandle,
 };
 use picker::{Picker, PickerDelegate};
 use project::{Project, ProjectPath, WorktreeId};
@@ -226,7 +226,7 @@ impl PickerDelegate for FileFinder {
     fn render_match(
         &self,
         ix: usize,
-        mouse_state: &MouseState,
+        mouse_state: MouseState,
         selected: bool,
         cx: &AppContext,
     ) -> ElementBox {
@@ -257,11 +257,9 @@ impl PickerDelegate for FileFinder {
 mod tests {
     use super::*;
     use editor::{Editor, Input};
+    use menu::{Confirm, SelectNext};
     use serde_json::json;
-    use workspace::{
-        menu::{Confirm, SelectNext},
-        AppState, Workspace,
-    };
+    use workspace::{AppState, Workspace};
 
     #[ctor::ctor]
     fn init_logger() {

crates/go_to_line/Cargo.toml 🔗

@@ -8,9 +8,10 @@ path = "src/go_to_line.rs"
 doctest = false
 
 [dependencies]
-text = { path = "../text" }
 editor = { path = "../editor" }
 gpui = { path = "../gpui" }
+menu = { path = "../menu" }
 settings = { path = "../settings" }
+text = { path = "../text" }
 workspace = { path = "../workspace" }
 postage = { version = "0.4", features = ["futures-traits"] }

crates/go_to_line/src/go_to_line.rs 🔗

@@ -3,12 +3,10 @@ use gpui::{
     actions, elements::*, geometry::vector::Vector2F, Axis, Entity, MutableAppContext,
     RenderContext, View, ViewContext, ViewHandle,
 };
+use menu::{Cancel, Confirm};
 use settings::Settings;
 use text::{Bias, Point};
-use workspace::{
-    menu::{Cancel, Confirm},
-    Workspace,
-};
+use workspace::Workspace;
 
 actions!(go_to_line, [Toggle]);
 

crates/gpui/src/app.rs 🔗

@@ -7,7 +7,8 @@ use crate::{
     platform::{self, Platform, PromptLevel, WindowOptions},
     presenter::Presenter,
     util::post_inc,
-    AssetCache, AssetSource, ClipboardItem, FontCache, PathPromptOptions, TextLayoutCache,
+    AssetCache, AssetSource, ClipboardItem, FontCache, MouseRegionId, PathPromptOptions,
+    TextLayoutCache,
 };
 pub use action::*;
 use anyhow::{anyhow, Context, Result};
@@ -126,26 +127,6 @@ pub trait UpdateView {
         T: View;
 }
 
-pub trait ElementStateContext: DerefMut<Target = MutableAppContext> {
-    fn current_view_id(&self) -> usize;
-
-    fn element_state<Tag: 'static, T: 'static + Default>(
-        &mut self,
-        element_id: usize,
-    ) -> ElementStateHandle<T> {
-        let id = ElementStateId {
-            view_id: self.current_view_id(),
-            element_id,
-            tag: TypeId::of::<Tag>(),
-        };
-        self.cx
-            .element_states
-            .entry(id)
-            .or_insert_with(|| Box::new(T::default()));
-        ElementStateHandle::new(id, self.frame_count, &self.cx.ref_counts)
-    }
-}
-
 pub struct Menu<'a> {
     pub name: &'a str,
     pub items: Vec<MenuItem<'a>>,
@@ -467,6 +448,27 @@ impl TestAppContext {
         result
     }
 
+    pub fn render<F, V, T>(&mut self, handle: &ViewHandle<V>, f: F) -> T
+    where
+        F: FnOnce(&mut V, &mut RenderContext<V>) -> T,
+        V: View,
+    {
+        handle.update(&mut *self.cx.borrow_mut(), |view, cx| {
+            let mut render_cx = RenderContext {
+                app: cx,
+                window_id: handle.window_id(),
+                view_id: handle.id(),
+                view_type: PhantomData,
+                titlebar_height: 0.,
+                hovered_region_ids: Default::default(),
+                clicked_region_id: None,
+                right_clicked_region_id: None,
+                refreshing: false,
+            };
+            f(view, &mut render_cx)
+        })
+    }
+
     pub fn to_async(&self) -> AsyncAppContext {
         AsyncAppContext(self.cx.clone())
     }
@@ -768,6 +770,7 @@ type ObservationCallback = Box<dyn FnMut(&mut MutableAppContext) -> bool>;
 type FocusObservationCallback = Box<dyn FnMut(&mut MutableAppContext) -> bool>;
 type GlobalObservationCallback = Box<dyn FnMut(&mut MutableAppContext)>;
 type ReleaseObservationCallback = Box<dyn FnOnce(&dyn Any, &mut MutableAppContext)>;
+type ActionObservationCallback = Box<dyn FnMut(TypeId, &mut MutableAppContext)>;
 type DeserializeActionCallback = fn(json: &str) -> anyhow::Result<Box<dyn Action>>;
 
 pub struct MutableAppContext {
@@ -793,6 +796,7 @@ pub struct MutableAppContext {
     global_observations:
         Arc<Mutex<HashMap<TypeId, BTreeMap<usize, Option<GlobalObservationCallback>>>>>,
     release_observations: Arc<Mutex<HashMap<usize, BTreeMap<usize, ReleaseObservationCallback>>>>,
+    action_dispatch_observations: Arc<Mutex<BTreeMap<usize, ActionObservationCallback>>>,
     presenters_and_platform_windows:
         HashMap<usize, (Rc<RefCell<Presenter>>, Box<dyn platform::Window>)>,
     foreground: Rc<executor::Foreground>,
@@ -845,6 +849,7 @@ impl MutableAppContext {
             focus_observations: Default::default(),
             release_observations: Default::default(),
             global_observations: Default::default(),
+            action_dispatch_observations: Default::default(),
             presenters_and_platform_windows: HashMap::new(),
             foreground,
             pending_effects: VecDeque::new(),
@@ -1051,19 +1056,15 @@ impl MutableAppContext {
             .map_or(false, |window| window.is_active)
     }
 
-    pub fn render_view(
-        &mut self,
-        window_id: usize,
-        view_id: usize,
-        titlebar_height: f32,
-        refreshing: bool,
-    ) -> Result<ElementBox> {
+    pub fn render_view(&mut self, params: RenderParams) -> Result<ElementBox> {
+        let window_id = params.window_id;
+        let view_id = params.view_id;
         let mut view = self
             .cx
             .views
-            .remove(&(window_id, view_id))
+            .remove(&(params.window_id, params.view_id))
             .ok_or(anyhow!("view not found"))?;
-        let element = view.render(window_id, view_id, titlebar_height, refreshing, self);
+        let element = view.render(params, self);
         self.cx.views.insert((window_id, view_id), view);
         Ok(element)
     }
@@ -1090,8 +1091,16 @@ impl MutableAppContext {
             .map(|view_id| {
                 (
                     view_id,
-                    self.render_view(window_id, view_id, titlebar_height, false)
-                        .unwrap(),
+                    self.render_view(RenderParams {
+                        window_id,
+                        view_id,
+                        titlebar_height,
+                        hovered_region_ids: Default::default(),
+                        clicked_region_id: None,
+                        right_clicked_region_id: None,
+                        refreshing: false,
+                    })
+                    .unwrap(),
                 )
             })
             .collect()
@@ -1322,6 +1331,20 @@ impl MutableAppContext {
         }
     }
 
+    pub fn observe_actions<F>(&mut self, callback: F) -> Subscription
+    where
+        F: 'static + FnMut(TypeId, &mut MutableAppContext),
+    {
+        let id = post_inc(&mut self.next_subscription_id);
+        self.action_dispatch_observations
+            .lock()
+            .insert(id, Box::new(callback));
+        Subscription::ActionObservation {
+            id,
+            observations: Some(Arc::downgrade(&self.action_dispatch_observations)),
+        }
+    }
+
     pub fn defer(&mut self, callback: impl 'static + FnOnce(&mut MutableAppContext)) {
         self.pending_effects.push_back(Effect::Deferred {
             callback: Box::new(callback),
@@ -1374,7 +1397,10 @@ impl MutableAppContext {
             .unwrap()
             .0
             .clone();
-        let dispatch_path = presenter.borrow().dispatch_path_from(view_id);
+        let mut dispatch_path = Vec::new();
+        presenter
+            .borrow()
+            .compute_dispatch_path_from(view_id, &mut dispatch_path);
         for view_id in dispatch_path {
             if let Some(view) = self.views.get(&(window_id, view_id)) {
                 let view_type = view.as_any().type_id();
@@ -1421,6 +1447,29 @@ impl MutableAppContext {
         self.global_actions.contains_key(&action_type)
     }
 
+    /// Return keystrokes that would dispatch the given action closest to the focused view, if there are any.
+    pub(crate) fn keystrokes_for_action(
+        &self,
+        window_id: usize,
+        dispatch_path: &[usize],
+        action: &dyn Action,
+    ) -> Option<SmallVec<[Keystroke; 2]>> {
+        for view_id in dispatch_path.iter().rev() {
+            let view = self
+                .cx
+                .views
+                .get(&(window_id, *view_id))
+                .expect("view in responder chain does not exist");
+            let cx = view.keymap_context(self.as_ref());
+            let keystrokes = self.keystroke_matcher.keystrokes_for_action(action, &cx);
+            if keystrokes.is_some() {
+                return keystrokes;
+            }
+        }
+
+        None
+    }
+
     pub fn dispatch_action_at(&mut self, window_id: usize, view_id: usize, action: &dyn Action) {
         let presenter = self
             .presenters_and_platform_windows
@@ -1428,7 +1477,10 @@ impl MutableAppContext {
             .unwrap()
             .0
             .clone();
-        let dispatch_path = presenter.borrow().dispatch_path_from(view_id);
+        let mut dispatch_path = Vec::new();
+        presenter
+            .borrow()
+            .compute_dispatch_path_from(view_id, &mut dispatch_path);
         self.dispatch_action_any(window_id, &dispatch_path, action);
     }
 
@@ -1486,6 +1538,11 @@ impl MutableAppContext {
             if !this.halt_action_dispatch {
                 this.halt_action_dispatch = this.dispatch_global_action_any(action);
             }
+
+            this.pending_effects
+                .push_back(Effect::ActionDispatchNotification {
+                    action_id: action.id(),
+                });
             this.halt_action_dispatch
         })
     }
@@ -1760,23 +1817,6 @@ impl MutableAppContext {
         )
     }
 
-    pub fn build_render_context<V: View>(
-        &mut self,
-        window_id: usize,
-        view_id: usize,
-        titlebar_height: f32,
-        refreshing: bool,
-    ) -> RenderContext<V> {
-        RenderContext {
-            app: self,
-            titlebar_height,
-            refreshing,
-            window_id,
-            view_id,
-            view_type: PhantomData,
-        }
-    }
-
     pub fn add_view<T, F>(&mut self, window_id: usize, build_view: F) -> ViewHandle<T>
     where
         T: View,
@@ -1951,6 +1991,9 @@ impl MutableAppContext {
                         Effect::RefreshWindows => {
                             refreshing = true;
                         }
+                        Effect::ActionDispatchNotification { action_id } => {
+                            self.handle_action_dispatch_notification_effect(action_id)
+                        }
                     }
                     self.pending_notifications.clear();
                     self.remove_dropped_entities();
@@ -2226,21 +2269,22 @@ impl MutableAppContext {
         observed_window_id: usize,
         observed_view_id: usize,
     ) {
-        if let Some(window) = self.cx.windows.get_mut(&observed_window_id) {
-            window
-                .invalidation
-                .get_or_insert_with(Default::default)
-                .updated
-                .insert(observed_view_id);
-        }
-
         let callbacks = self.observations.lock().remove(&observed_view_id);
-        if let Some(callbacks) = callbacks {
-            if self
-                .cx
-                .views
-                .contains_key(&(observed_window_id, observed_view_id))
-            {
+
+        if self
+            .cx
+            .views
+            .contains_key(&(observed_window_id, observed_view_id))
+        {
+            if let Some(window) = self.cx.windows.get_mut(&observed_window_id) {
+                window
+                    .invalidation
+                    .get_or_insert_with(Default::default)
+                    .updated
+                    .insert(observed_view_id);
+            }
+
+            if let Some(callbacks) = callbacks {
                 for (id, callback) in callbacks {
                     if let Some(mut callback) = callback {
                         let alive = callback(self);
@@ -2389,7 +2433,15 @@ impl MutableAppContext {
         })
     }
 
-    fn focus(&mut self, window_id: usize, view_id: Option<usize>) {
+    fn handle_action_dispatch_notification_effect(&mut self, action_id: TypeId) {
+        let mut callbacks = mem::take(&mut *self.action_dispatch_observations.lock());
+        for (_, callback) in &mut callbacks {
+            callback(action_id, self);
+        }
+        self.action_dispatch_observations.lock().extend(callbacks);
+    }
+
+    pub fn focus(&mut self, window_id: usize, view_id: Option<usize>) {
         if let Some(pending_focus_index) = self.pending_focus_index {
             self.pending_effects.remove(pending_focus_index);
         }
@@ -2763,6 +2815,9 @@ pub enum Effect {
         is_active: bool,
     },
     RefreshWindows,
+    ActionDispatchNotification {
+        action_id: TypeId,
+    },
 }
 
 impl Debug for Effect {
@@ -2839,6 +2894,10 @@ impl Debug for Effect {
                 .field("view_id", view_id)
                 .field("subscription_id", subscription_id)
                 .finish(),
+            Effect::ActionDispatchNotification { action_id, .. } => f
+                .debug_struct("Effect::ActionDispatchNotification")
+                .field("action_id", action_id)
+                .finish(),
             Effect::ResizeWindow { window_id } => f
                 .debug_struct("Effect::RefreshWindow")
                 .field("window_id", window_id)
@@ -2899,14 +2958,7 @@ pub trait AnyView {
         cx: &mut MutableAppContext,
     ) -> Option<Pin<Box<dyn 'static + Future<Output = ()>>>>;
     fn ui_name(&self) -> &'static str;
-    fn render<'a>(
-        &mut self,
-        window_id: usize,
-        view_id: usize,
-        titlebar_height: f32,
-        refreshing: bool,
-        cx: &mut MutableAppContext,
-    ) -> ElementBox;
+    fn render<'a>(&mut self, params: RenderParams, cx: &mut MutableAppContext) -> ElementBox;
     fn on_focus(&mut self, cx: &mut MutableAppContext, window_id: usize, view_id: usize);
     fn on_blur(&mut self, cx: &mut MutableAppContext, window_id: usize, view_id: usize);
     fn keymap_context(&self, cx: &AppContext) -> keymap::Context;
@@ -2940,25 +2992,8 @@ where
         T::ui_name()
     }
 
-    fn render<'a>(
-        &mut self,
-        window_id: usize,
-        view_id: usize,
-        titlebar_height: f32,
-        refreshing: bool,
-        cx: &mut MutableAppContext,
-    ) -> ElementBox {
-        View::render(
-            self,
-            &mut RenderContext {
-                window_id,
-                view_id,
-                app: cx,
-                view_type: PhantomData::<T>,
-                titlebar_height,
-                refreshing,
-            },
-        )
+    fn render<'a>(&mut self, params: RenderParams, cx: &mut MutableAppContext) -> ElementBox {
+        View::render(self, &mut RenderContext::new(params, cx))
     }
 
     fn on_focus(&mut self, cx: &mut MutableAppContext, window_id: usize, view_id: usize) {
@@ -3266,6 +3301,10 @@ impl<'a, T: View> ViewContext<'a, T> {
         self.app.focus(self.window_id, Some(self.view_id));
     }
 
+    pub fn is_self_focused(&self) -> bool {
+        self.app.focused_view_id(self.window_id) == Some(self.view_id)
+    }
+
     pub fn blur(&mut self) {
         self.app.focus(self.window_id, None);
     }
@@ -3390,6 +3429,20 @@ impl<'a, T: View> ViewContext<'a, T> {
         })
     }
 
+    pub fn observe_actions<F>(&mut self, mut callback: F) -> Subscription
+    where
+        F: 'static + FnMut(&mut T, TypeId, &mut ViewContext<T>),
+    {
+        let observer = self.weak_handle();
+        self.app.observe_actions(move |action_id, cx| {
+            if let Some(observer) = observer.upgrade(cx) {
+                observer.update(cx, |observer, cx| {
+                    callback(observer, action_id, cx);
+                });
+            }
+        })
+    }
+
     pub fn emit(&mut self, payload: T::Event) {
         self.app.pending_effects.push_back(Effect::Event {
             entity_id: self.view_id,
@@ -3447,23 +3500,85 @@ impl<'a, T: View> ViewContext<'a, T> {
     }
 }
 
+pub struct RenderParams {
+    pub window_id: usize,
+    pub view_id: usize,
+    pub titlebar_height: f32,
+    pub hovered_region_ids: HashSet<MouseRegionId>,
+    pub clicked_region_id: Option<MouseRegionId>,
+    pub right_clicked_region_id: Option<MouseRegionId>,
+    pub refreshing: bool,
+}
+
 pub struct RenderContext<'a, T: View> {
+    pub(crate) window_id: usize,
+    pub(crate) view_id: usize,
+    pub(crate) view_type: PhantomData<T>,
+    pub(crate) hovered_region_ids: HashSet<MouseRegionId>,
+    pub(crate) clicked_region_id: Option<MouseRegionId>,
+    pub(crate) right_clicked_region_id: Option<MouseRegionId>,
     pub app: &'a mut MutableAppContext,
     pub titlebar_height: f32,
     pub refreshing: bool,
-    window_id: usize,
-    view_id: usize,
-    view_type: PhantomData<T>,
 }
 
-impl<'a, T: View> RenderContext<'a, T> {
-    pub fn handle(&self) -> WeakViewHandle<T> {
+#[derive(Clone, Copy, Default)]
+pub struct MouseState {
+    pub hovered: bool,
+    pub clicked: bool,
+    pub right_clicked: bool,
+}
+
+impl<'a, V: View> RenderContext<'a, V> {
+    fn new(params: RenderParams, app: &'a mut MutableAppContext) -> Self {
+        Self {
+            app,
+            window_id: params.window_id,
+            view_id: params.view_id,
+            view_type: PhantomData,
+            titlebar_height: params.titlebar_height,
+            hovered_region_ids: params.hovered_region_ids.clone(),
+            clicked_region_id: params.clicked_region_id,
+            right_clicked_region_id: params.right_clicked_region_id,
+            refreshing: params.refreshing,
+        }
+    }
+
+    pub fn handle(&self) -> WeakViewHandle<V> {
         WeakViewHandle::new(self.window_id, self.view_id)
     }
 
     pub fn view_id(&self) -> usize {
         self.view_id
     }
+
+    pub fn mouse_state<Tag: 'static>(&self, region_id: usize) -> MouseState {
+        let region_id = MouseRegionId {
+            view_id: self.view_id,
+            discriminant: (TypeId::of::<Tag>(), region_id),
+        };
+        MouseState {
+            hovered: self.hovered_region_ids.contains(&region_id),
+            clicked: self.clicked_region_id == Some(region_id),
+            right_clicked: self.right_clicked_region_id == Some(region_id),
+        }
+    }
+
+    pub fn element_state<Tag: 'static, T: 'static + Default>(
+        &mut self,
+        element_id: usize,
+    ) -> ElementStateHandle<T> {
+        let id = ElementStateId {
+            view_id: self.view_id(),
+            element_id,
+            tag: TypeId::of::<Tag>(),
+        };
+        self.cx
+            .element_states
+            .entry(id)
+            .or_insert_with(|| Box::new(T::default()));
+        ElementStateHandle::new(id, self.frame_count, &self.cx.ref_counts)
+    }
 }
 
 impl AsRef<AppContext> for &AppContext {
@@ -3508,12 +3623,6 @@ impl<V: View> ReadView for RenderContext<'_, V> {
     }
 }
 
-impl<V: View> ElementStateContext for RenderContext<'_, V> {
-    fn current_view_id(&self) -> usize {
-        self.view_id
-    }
-}
-
 impl<M> AsRef<AppContext> for ViewContext<'_, M> {
     fn as_ref(&self) -> &AppContext {
         &self.app.cx
@@ -3573,6 +3682,16 @@ impl<V> UpgradeViewHandle for ViewContext<'_, V> {
     }
 }
 
+impl<V: View> UpgradeViewHandle for RenderContext<'_, V> {
+    fn upgrade_view_handle<T: View>(&self, handle: &WeakViewHandle<T>) -> Option<ViewHandle<T>> {
+        self.cx.upgrade_view_handle(handle)
+    }
+
+    fn upgrade_any_view_handle(&self, handle: &AnyWeakViewHandle) -> Option<AnyViewHandle> {
+        self.cx.upgrade_any_view_handle(handle)
+    }
+}
+
 impl<V: View> UpdateModel for ViewContext<'_, V> {
     fn update_model<T: Entity, O>(
         &mut self,
@@ -3602,12 +3721,6 @@ impl<V: View> UpdateView for ViewContext<'_, V> {
     }
 }
 
-impl<V: View> ElementStateContext for ViewContext<'_, V> {
-    fn current_view_id(&self) -> usize {
-        self.view_id
-    }
-}
-
 pub trait Handle<T> {
     type Weak: 'static;
     fn id(&self) -> usize;
@@ -4636,6 +4749,10 @@ pub enum Subscription {
         observations:
             Option<Weak<Mutex<HashMap<usize, BTreeMap<usize, ReleaseObservationCallback>>>>>,
     },
+    ActionObservation {
+        id: usize,
+        observations: Option<Weak<Mutex<BTreeMap<usize, ActionObservationCallback>>>>,
+    },
 }
 
 impl Subscription {
@@ -4659,6 +4776,9 @@ impl Subscription {
             Subscription::FocusObservation { observations, .. } => {
                 observations.take();
             }
+            Subscription::ActionObservation { observations, .. } => {
+                observations.take();
+            }
         }
     }
 }
@@ -4767,6 +4887,11 @@ impl Drop for Subscription {
                     }
                 }
             }
+            Subscription::ActionObservation { id, observations } => {
+                if let Some(observations) = observations.as_ref().and_then(Weak::upgrade) {
+                    observations.lock().remove(&id);
+                }
+            }
         }
     }
 }
@@ -6200,7 +6325,7 @@ mod tests {
             }
         }
 
-        #[derive(Clone, Deserialize)]
+        #[derive(Clone, Default, Deserialize)]
         pub struct Action(pub String);
 
         impl_actions!(test, [Action]);
@@ -6265,6 +6390,13 @@ mod tests {
         let view_3 = cx.add_view(window_id, |_| ViewA { id: 3 });
         let view_4 = cx.add_view(window_id, |_| ViewB { id: 4 });
 
+        let observed_actions = Rc::new(RefCell::new(Vec::new()));
+        cx.observe_actions({
+            let observed_actions = observed_actions.clone();
+            move |action_id, _| observed_actions.borrow_mut().push(action_id)
+        })
+        .detach();
+
         cx.dispatch_action(
             window_id,
             vec![view_1.id(), view_2.id(), view_3.id(), view_4.id()],
@@ -6285,6 +6417,7 @@ mod tests {
                 "1 b"
             ]
         );
+        assert_eq!(*observed_actions.borrow(), [Action::default().id()]);
 
         // Remove view_1, which doesn't propagate the action
         actions.borrow_mut().clear();
@@ -6307,6 +6440,10 @@ mod tests {
                 "global"
             ]
         );
+        assert_eq!(
+            *observed_actions.borrow(),
+            [Action::default().id(), Action::default().id()]
+        );
     }
 
     #[crate::test(self)]

crates/gpui/src/elements.rs 🔗

@@ -8,6 +8,7 @@ mod expanded;
 mod flex;
 mod hook;
 mod image;
+mod keystroke_label;
 mod label;
 mod list;
 mod mouse_event_handler;
@@ -20,8 +21,8 @@ mod uniform_list;
 use self::expanded::Expanded;
 pub use self::{
     align::*, canvas::*, constrained_box::*, container::*, empty::*, event_handler::*, flex::*,
-    hook::*, image::*, label::*, list::*, mouse_event_handler::*, overlay::*, stack::*, svg::*,
-    text::*, uniform_list::*,
+    hook::*, image::*, keystroke_label::*, label::*, list::*, mouse_event_handler::*, overlay::*,
+    stack::*, svg::*, text::*, uniform_list::*,
 };
 pub use crate::presenter::ChildView;
 use crate::{

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

@@ -9,46 +9,121 @@ use crate::{
 
 pub struct ConstrainedBox {
     child: ElementBox,
-    constraint: SizeConstraint,
+    constraint: Constraint,
+}
+
+pub enum Constraint {
+    Static(SizeConstraint),
+    Dynamic(Box<dyn FnMut(SizeConstraint, &mut LayoutContext) -> SizeConstraint>),
+}
+
+impl ToJson for Constraint {
+    fn to_json(&self) -> serde_json::Value {
+        match self {
+            Constraint::Static(constraint) => constraint.to_json(),
+            Constraint::Dynamic(_) => "dynamic".into(),
+        }
+    }
 }
 
 impl ConstrainedBox {
     pub fn new(child: ElementBox) -> Self {
         Self {
             child,
-            constraint: SizeConstraint {
-                min: Vector2F::zero(),
-                max: Vector2F::splat(f32::INFINITY),
-            },
+            constraint: Constraint::Static(Default::default()),
         }
     }
 
+    pub fn dynamically(
+        mut self,
+        constraint: impl 'static + FnMut(SizeConstraint, &mut LayoutContext) -> SizeConstraint,
+    ) -> Self {
+        self.constraint = Constraint::Dynamic(Box::new(constraint));
+        self
+    }
+
     pub fn with_min_width(mut self, min_width: f32) -> Self {
-        self.constraint.min.set_x(min_width);
+        if let Constraint::Dynamic(_) = self.constraint {
+            self.constraint = Constraint::Static(Default::default());
+        }
+
+        if let Constraint::Static(constraint) = &mut self.constraint {
+            constraint.min.set_x(min_width);
+        } else {
+            unreachable!()
+        }
+
         self
     }
 
     pub fn with_max_width(mut self, max_width: f32) -> Self {
-        self.constraint.max.set_x(max_width);
+        if let Constraint::Dynamic(_) = self.constraint {
+            self.constraint = Constraint::Static(Default::default());
+        }
+
+        if let Constraint::Static(constraint) = &mut self.constraint {
+            constraint.max.set_x(max_width);
+        } else {
+            unreachable!()
+        }
+
         self
     }
 
     pub fn with_max_height(mut self, max_height: f32) -> Self {
-        self.constraint.max.set_y(max_height);
+        if let Constraint::Dynamic(_) = self.constraint {
+            self.constraint = Constraint::Static(Default::default());
+        }
+
+        if let Constraint::Static(constraint) = &mut self.constraint {
+            constraint.max.set_y(max_height);
+        } else {
+            unreachable!()
+        }
+
         self
     }
 
     pub fn with_width(mut self, width: f32) -> Self {
-        self.constraint.min.set_x(width);
-        self.constraint.max.set_x(width);
+        if let Constraint::Dynamic(_) = self.constraint {
+            self.constraint = Constraint::Static(Default::default());
+        }
+
+        if let Constraint::Static(constraint) = &mut self.constraint {
+            constraint.min.set_x(width);
+            constraint.max.set_x(width);
+        } else {
+            unreachable!()
+        }
+
         self
     }
 
     pub fn with_height(mut self, height: f32) -> Self {
-        self.constraint.min.set_y(height);
-        self.constraint.max.set_y(height);
+        if let Constraint::Dynamic(_) = self.constraint {
+            self.constraint = Constraint::Static(Default::default());
+        }
+
+        if let Constraint::Static(constraint) = &mut self.constraint {
+            constraint.min.set_y(height);
+            constraint.max.set_y(height);
+        } else {
+            unreachable!()
+        }
+
         self
     }
+
+    fn constraint(
+        &mut self,
+        input_constraint: SizeConstraint,
+        cx: &mut LayoutContext,
+    ) -> SizeConstraint {
+        match &mut self.constraint {
+            Constraint::Static(constraint) => *constraint,
+            Constraint::Dynamic(compute_constraint) => compute_constraint(input_constraint, cx),
+        }
+    }
 }
 
 impl Element for ConstrainedBox {
@@ -57,13 +132,14 @@ impl Element for ConstrainedBox {
 
     fn layout(
         &mut self,
-        mut constraint: SizeConstraint,
+        mut parent_constraint: SizeConstraint,
         cx: &mut LayoutContext,
     ) -> (Vector2F, Self::LayoutState) {
-        constraint.min = constraint.min.max(self.constraint.min);
-        constraint.max = constraint.max.min(self.constraint.max);
-        constraint.max = constraint.max.max(constraint.min);
-        let size = self.child.layout(constraint, cx);
+        let constraint = self.constraint(parent_constraint, cx);
+        parent_constraint.min = parent_constraint.min.max(constraint.min);
+        parent_constraint.max = parent_constraint.max.min(constraint.max);
+        parent_constraint.max = parent_constraint.max.max(parent_constraint.min);
+        let size = self.child.layout(parent_constraint, cx);
         (size, ())
     }
 
@@ -96,6 +172,6 @@ impl Element for ConstrainedBox {
         _: &Self::PaintState,
         cx: &DebugContext,
     ) -> json::Value {
-        json!({"type": "ConstrainedBox", "set_constraint": self.constraint.to_json(), "child": self.child.debug(cx)})
+        json!({"type": "ConstrainedBox", "assigned_constraint": self.constraint.to_json(), "child": self.child.debug(cx)})
     }
 }

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

@@ -7,7 +7,7 @@ use crate::{
     },
     json::ToJson,
     platform::CursorStyle,
-    scene::{self, Border, Quad},
+    scene::{self, Border, CursorRegion, Quad},
     Element, ElementBox, Event, EventContext, LayoutContext, PaintContext, SizeConstraint,
 };
 use serde::Deserialize;
@@ -213,7 +213,10 @@ impl Element for Container {
         }
 
         if let Some(style) = self.style.cursor {
-            cx.scene.push_cursor_style(quad_bounds, style);
+            cx.scene.push_cursor_region(CursorRegion {
+                bounds: quad_bounds,
+                style,
+            });
         }
 
         let child_origin =

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

@@ -2,8 +2,8 @@ use std::{any::Any, f32::INFINITY};
 
 use crate::{
     json::{self, ToJson, Value},
-    Axis, DebugContext, Element, ElementBox, ElementStateContext, ElementStateHandle, Event,
-    EventContext, LayoutContext, PaintContext, SizeConstraint, Vector2FExt,
+    Axis, DebugContext, Element, ElementBox, ElementStateHandle, Event, EventContext,
+    LayoutContext, PaintContext, RenderContext, SizeConstraint, Vector2FExt, View,
 };
 use pathfinder_geometry::{
     rect::RectF,
@@ -40,15 +40,15 @@ impl Flex {
         Self::new(Axis::Vertical)
     }
 
-    pub fn scrollable<Tag, C>(
+    pub fn scrollable<Tag, V>(
         mut self,
         element_id: usize,
         scroll_to: Option<usize>,
-        cx: &mut C,
+        cx: &mut RenderContext<V>,
     ) -> Self
     where
         Tag: 'static,
-        C: ElementStateContext,
+        V: View,
     {
         let scroll_state = cx.element_state::<Tag, ScrollState>(element_id);
         scroll_state.update(cx, |scroll_state, _| scroll_state.scroll_to = scroll_to);

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

@@ -0,0 +1,95 @@
+use crate::{
+    elements::*,
+    fonts::TextStyle,
+    geometry::{rect::RectF, vector::Vector2F},
+    Action, ElementBox, Event, EventContext, LayoutContext, PaintContext, SizeConstraint,
+};
+use serde_json::json;
+
+use super::ContainerStyle;
+
+pub struct KeystrokeLabel {
+    action: Box<dyn Action>,
+    container_style: ContainerStyle,
+    text_style: TextStyle,
+}
+
+impl KeystrokeLabel {
+    pub fn new(
+        action: Box<dyn Action>,
+        container_style: ContainerStyle,
+        text_style: TextStyle,
+    ) -> Self {
+        Self {
+            action,
+            container_style,
+            text_style,
+        }
+    }
+}
+
+impl Element for KeystrokeLabel {
+    type LayoutState = ElementBox;
+    type PaintState = ();
+
+    fn layout(
+        &mut self,
+        constraint: SizeConstraint,
+        cx: &mut LayoutContext,
+    ) -> (Vector2F, ElementBox) {
+        let mut element = if let Some(keystrokes) = cx.keystrokes_for_action(self.action.as_ref()) {
+            Flex::row()
+                .with_children(keystrokes.iter().map(|keystroke| {
+                    Label::new(
+                        keystroke.to_string().to_uppercase(),
+                        self.text_style.clone(),
+                    )
+                    .contained()
+                    .with_style(self.container_style)
+                    .boxed()
+                }))
+                .boxed()
+        } else {
+            Empty::new().collapsed().boxed()
+        };
+
+        let size = element.layout(constraint, cx);
+        (size, element)
+    }
+
+    fn paint(
+        &mut self,
+        bounds: RectF,
+        visible_bounds: RectF,
+        element: &mut ElementBox,
+        cx: &mut PaintContext,
+    ) {
+        element.paint(bounds.origin(), visible_bounds, cx);
+    }
+
+    fn dispatch_event(
+        &mut self,
+        event: &Event,
+        _: RectF,
+        _: RectF,
+        element: &mut ElementBox,
+        _: &mut (),
+        cx: &mut EventContext,
+    ) -> bool {
+        element.dispatch_event(event, cx)
+    }
+
+    fn debug(
+        &self,
+        _: RectF,
+        element: &ElementBox,
+        _: &(),
+        cx: &crate::DebugContext,
+    ) -> serde_json::Value {
+        json!({
+            "type": "KeystrokeLabel",
+            "action": self.action.name(),
+            "child": element.debug(cx)
+        })
+    }
+}

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

@@ -5,7 +5,7 @@ use crate::{
     },
     json::json,
     DebugContext, Element, ElementBox, ElementRc, Event, EventContext, LayoutContext, PaintContext,
-    SizeConstraint,
+    RenderContext, SizeConstraint, View, ViewContext,
 };
 use std::{cell::RefCell, collections::VecDeque, ops::Range, rc::Rc};
 use sum_tree::{Bias, SumTree};
@@ -26,7 +26,7 @@ pub enum Orientation {
 
 struct StateInner {
     last_layout_width: Option<f32>,
-    render_item: Box<dyn FnMut(usize, &mut LayoutContext) -> ElementBox>,
+    render_item: Box<dyn FnMut(usize, &mut LayoutContext) -> Option<ElementBox>>,
     rendered_range: Range<usize>,
     items: SumTree<ListItem>,
     logical_scroll_top: Option<ListOffset>,
@@ -131,13 +131,27 @@ impl Element for List {
         let mut cursor = old_items.cursor::<Count>();
         cursor.seek(&Count(scroll_top.item_ix), Bias::Right, &());
         for (ix, item) in cursor.by_ref().enumerate() {
-            if rendered_height - scroll_top.offset_in_item >= size.y() + state.overdraw {
+            let visible_height = rendered_height - scroll_top.offset_in_item;
+            if visible_height >= size.y() + state.overdraw {
                 break;
             }
 
-            let element = state.render_item(scroll_top.item_ix + ix, item, item_constraint, cx);
-            rendered_height += element.size().y();
-            rendered_items.push_back(ListItem::Rendered(element));
+            // Force re-render if the item is visible, but attempt to re-use an existing one
+            // if we are inside the overdraw.
+            let existing_element = if visible_height >= size.y() {
+                Some(item)
+            } else {
+                None
+            };
+            if let Some(element) = state.render_item(
+                scroll_top.item_ix + ix,
+                existing_element,
+                item_constraint,
+                cx,
+            ) {
+                rendered_height += element.size().y();
+                rendered_items.push_back(ListItem::Rendered(element));
+            }
         }
 
         // Prepare to start walking upward from the item at the scroll top.
@@ -148,10 +162,13 @@ impl Element for List {
         if rendered_height - scroll_top.offset_in_item < size.y() {
             while rendered_height < size.y() {
                 cursor.prev(&());
-                if let Some(item) = cursor.item() {
-                    let element = state.render_item(cursor.start().0, item, item_constraint, cx);
-                    rendered_height += element.size().y();
-                    rendered_items.push_front(ListItem::Rendered(element));
+                if cursor.item().is_some() {
+                    if let Some(element) =
+                        state.render_item(cursor.start().0, None, item_constraint, cx)
+                    {
+                        rendered_height += element.size().y();
+                        rendered_items.push_front(ListItem::Rendered(element));
+                    }
                 } else {
                     break;
                 }
@@ -182,9 +199,12 @@ impl Element for List {
         while leading_overdraw < state.overdraw {
             cursor.prev(&());
             if let Some(item) = cursor.item() {
-                let element = state.render_item(cursor.start().0, item, item_constraint, cx);
-                leading_overdraw += element.size().y();
-                rendered_items.push_front(ListItem::Rendered(element));
+                if let Some(element) =
+                    state.render_item(cursor.start().0, Some(item), item_constraint, cx)
+                {
+                    leading_overdraw += element.size().y();
+                    rendered_items.push_front(ListItem::Rendered(element));
+                }
             } else {
                 break;
             }
@@ -330,20 +350,26 @@ impl Element for List {
 }
 
 impl ListState {
-    pub fn new<F>(
+    pub fn new<F, V>(
         element_count: usize,
         orientation: Orientation,
         overdraw: f32,
-        render_item: F,
+        cx: &mut ViewContext<V>,
+        mut render_item: F,
     ) -> Self
     where
-        F: 'static + FnMut(usize, &mut LayoutContext) -> ElementBox,
+        V: View,
+        F: 'static + FnMut(&mut V, usize, &mut RenderContext<V>) -> ElementBox,
     {
         let mut items = SumTree::new();
         items.extend((0..element_count).map(|_| ListItem::Unrendered), &());
+        let handle = cx.weak_handle();
         Self(Rc::new(RefCell::new(StateInner {
             last_layout_width: None,
-            render_item: Box::new(render_item),
+            render_item: Box::new(move |ix, cx| {
+                let handle = handle.upgrade(cx)?;
+                Some(cx.render(&handle, |view, cx| render_item(view, ix, cx)))
+            }),
             rendered_range: 0..0,
             items,
             logical_scroll_top: None,
@@ -411,16 +437,16 @@ impl StateInner {
     fn render_item(
         &mut self,
         ix: usize,
-        existing_item: &ListItem,
+        existing_element: Option<&ListItem>,
         constraint: SizeConstraint,
         cx: &mut LayoutContext,
-    ) -> ElementRc {
-        if let ListItem::Rendered(element) = existing_item {
-            element.clone()
+    ) -> Option<ElementRc> {
+        if let Some(ListItem::Rendered(element)) = existing_element {
+            Some(element.clone())
         } else {
-            let mut element = (self.render_item)(ix, cx);
+            let mut element = (self.render_item)(ix, cx)?;
             element.layout(constraint, cx);
-            element.into()
+            Some(element.into())
         }
     }
 
@@ -593,26 +619,33 @@ impl<'a> sum_tree::SeekTarget<'a, ListItemSummary, ListItemSummary> for Height {
 #[cfg(test)]
 mod tests {
     use super::*;
-    use crate::geometry::vector::vec2f;
+    use crate::{elements::Empty, geometry::vector::vec2f, Entity};
     use rand::prelude::*;
     use std::env;
 
     #[crate::test(self)]
     fn test_layout(cx: &mut crate::MutableAppContext) {
         let mut presenter = cx.build_presenter(0, 0.);
+        let (_, view) = cx.add_window(Default::default(), |_| TestView);
         let constraint = SizeConstraint::new(vec2f(0., 0.), vec2f(100., 40.));
 
         let elements = Rc::new(RefCell::new(vec![(0, 20.), (1, 30.), (2, 100.)]));
-        let state = ListState::new(elements.borrow().len(), Orientation::Top, 1000.0, {
-            let elements = elements.clone();
-            move |ix, _| {
-                let (id, height) = elements.borrow()[ix];
-                TestElement::new(id, height).boxed()
-            }
+
+        let state = view.update(cx, |_, cx| {
+            ListState::new(elements.borrow().len(), Orientation::Top, 1000.0, cx, {
+                let elements = elements.clone();
+                move |_, ix, _| {
+                    let (id, height) = elements.borrow()[ix];
+                    TestElement::new(id, height).boxed()
+                }
+            })
         });
 
         let mut list = List::new(state.clone());
-        let (size, _) = list.layout(constraint, &mut presenter.build_layout_context(false, cx));
+        let (size, _) = list.layout(
+            constraint,
+            &mut presenter.build_layout_context(vec2f(100., 40.), false, cx),
+        );
         assert_eq!(size, vec2f(100., 40.));
         assert_eq!(
             state.0.borrow().items.summary().clone(),
@@ -634,8 +667,10 @@ mod tests {
             true,
             &mut presenter.build_event_context(cx),
         );
-        let (_, logical_scroll_top) =
-            list.layout(constraint, &mut presenter.build_layout_context(false, cx));
+        let (_, logical_scroll_top) = list.layout(
+            constraint,
+            &mut presenter.build_layout_context(vec2f(100., 40.), false, cx),
+        );
         assert_eq!(
             logical_scroll_top,
             ListOffset {
@@ -659,8 +694,10 @@ mod tests {
             }
         );
 
-        let (size, logical_scroll_top) =
-            list.layout(constraint, &mut presenter.build_layout_context(false, cx));
+        let (size, logical_scroll_top) = list.layout(
+            constraint,
+            &mut presenter.build_layout_context(vec2f(100., 40.), false, cx),
+        );
         assert_eq!(size, vec2f(100., 40.));
         assert_eq!(
             state.0.borrow().items.summary().clone(),
@@ -687,6 +724,7 @@ mod tests {
             .map(|i| i.parse().expect("invalid `OPERATIONS` variable"))
             .unwrap_or(10);
 
+        let (_, view) = cx.add_window(Default::default(), |_| TestView);
         let mut presenter = cx.build_presenter(0, 0.);
         let mut next_id = 0;
         let elements = Rc::new(RefCell::new(
@@ -702,12 +740,15 @@ mod tests {
             .choose(&mut rng)
             .unwrap();
         let overdraw = rng.gen_range(1..=100) as f32;
-        let state = ListState::new(elements.borrow().len(), orientation, overdraw, {
-            let elements = elements.clone();
-            move |ix, _| {
-                let (id, height) = elements.borrow()[ix];
-                TestElement::new(id, height).boxed()
-            }
+
+        let state = view.update(cx, |_, cx| {
+            ListState::new(elements.borrow().len(), orientation, overdraw, cx, {
+                let elements = elements.clone();
+                move |_, ix, _| {
+                    let (id, height) = elements.borrow()[ix];
+                    TestElement::new(id, height).boxed()
+                }
+            })
         });
 
         let mut width = rng.gen_range(0..=2000) as f32 / 2.;
@@ -770,11 +811,12 @@ mod tests {
             }
 
             let mut list = List::new(state.clone());
+            let window_size = vec2f(width, height);
             let (size, logical_scroll_top) = list.layout(
-                SizeConstraint::new(vec2f(0., 0.), vec2f(width, height)),
-                &mut presenter.build_layout_context(false, cx),
+                SizeConstraint::new(vec2f(0., 0.), window_size),
+                &mut presenter.build_layout_context(window_size, false, cx),
             );
-            assert_eq!(size, vec2f(width, height));
+            assert_eq!(size, window_size);
             last_logical_scroll_top = Some(logical_scroll_top);
 
             let state = state.0.borrow();
@@ -843,6 +885,22 @@ mod tests {
         }
     }
 
+    struct TestView;
+
+    impl Entity for TestView {
+        type Event = ();
+    }
+
+    impl View for TestView {
+        fn ui_name() -> &'static str {
+            "TestView"
+        }
+
+        fn render(&mut self, _: &mut RenderContext<'_, Self>) -> ElementBox {
+            Empty::new().boxed()
+        }
+    }
+
     struct TestElement {
         id: usize,
         size: Vector2F,

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

@@ -1,3 +1,5 @@
+use std::{any::TypeId, rc::Rc};
+
 use super::Padding;
 use crate::{
     geometry::{
@@ -5,44 +7,46 @@ use crate::{
         vector::{vec2f, Vector2F},
     },
     platform::CursorStyle,
-    DebugContext, Element, ElementBox, ElementStateContext, ElementStateHandle, Event,
-    EventContext, LayoutContext, PaintContext, SizeConstraint,
+    scene::CursorRegion,
+    DebugContext, Element, ElementBox, Event, EventContext, LayoutContext, MouseRegion, MouseState,
+    PaintContext, RenderContext, SizeConstraint, View,
 };
 use serde_json::json;
 
 pub struct MouseEventHandler {
-    state: ElementStateHandle<MouseState>,
     child: ElementBox,
+    tag: TypeId,
+    id: usize,
     cursor_style: Option<CursorStyle>,
-    mouse_down_handler: Option<Box<dyn FnMut(&mut EventContext)>>,
-    click_handler: Option<Box<dyn FnMut(usize, &mut EventContext)>>,
-    drag_handler: Option<Box<dyn FnMut(Vector2F, &mut EventContext)>>,
+    mouse_down: Option<Rc<dyn Fn(Vector2F, &mut EventContext)>>,
+    click: Option<Rc<dyn Fn(Vector2F, usize, &mut EventContext)>>,
+    right_mouse_down: Option<Rc<dyn Fn(Vector2F, &mut EventContext)>>,
+    right_click: Option<Rc<dyn Fn(Vector2F, usize, &mut EventContext)>>,
+    mouse_down_out: Option<Rc<dyn Fn(Vector2F, &mut EventContext)>>,
+    right_mouse_down_out: Option<Rc<dyn Fn(Vector2F, &mut EventContext)>>,
+    drag: Option<Rc<dyn Fn(Vector2F, &mut EventContext)>>,
     padding: Padding,
 }
 
-#[derive(Default)]
-pub struct MouseState {
-    pub hovered: bool,
-    pub clicked: bool,
-    prev_drag_position: Option<Vector2F>,
-}
-
 impl MouseEventHandler {
-    pub fn new<Tag, C, F>(id: usize, cx: &mut C, render_child: F) -> Self
+    pub fn new<Tag, V, F>(id: usize, cx: &mut RenderContext<V>, render_child: F) -> Self
     where
         Tag: 'static,
-        C: ElementStateContext,
-        F: FnOnce(&MouseState, &mut C) -> ElementBox,
+        V: View,
+        F: FnOnce(MouseState, &mut RenderContext<V>) -> ElementBox,
     {
-        let state_handle = cx.element_state::<Tag, _>(id);
-        let child = state_handle.update(cx, |state, cx| render_child(state, cx));
         Self {
-            state: state_handle,
-            child,
+            id,
+            tag: TypeId::of::<Tag>(),
+            child: render_child(cx.mouse_state::<Tag>(id), cx),
             cursor_style: None,
-            mouse_down_handler: None,
-            click_handler: None,
-            drag_handler: None,
+            mouse_down: None,
+            click: None,
+            right_mouse_down: None,
+            right_click: None,
+            mouse_down_out: None,
+            right_mouse_down_out: None,
+            drag: None,
             padding: Default::default(),
         }
     }
@@ -52,18 +56,56 @@ impl MouseEventHandler {
         self
     }
 
-    pub fn on_mouse_down(mut self, handler: impl FnMut(&mut EventContext) + 'static) -> Self {
-        self.mouse_down_handler = Some(Box::new(handler));
+    pub fn on_mouse_down(
+        mut self,
+        handler: impl Fn(Vector2F, &mut EventContext) + 'static,
+    ) -> Self {
+        self.mouse_down = Some(Rc::new(handler));
+        self
+    }
+
+    pub fn on_click(
+        mut self,
+        handler: impl Fn(Vector2F, usize, &mut EventContext) + 'static,
+    ) -> Self {
+        self.click = Some(Rc::new(handler));
+        self
+    }
+
+    pub fn on_right_mouse_down(
+        mut self,
+        handler: impl Fn(Vector2F, &mut EventContext) + 'static,
+    ) -> Self {
+        self.right_mouse_down = Some(Rc::new(handler));
+        self
+    }
+
+    pub fn on_right_click(
+        mut self,
+        handler: impl Fn(Vector2F, usize, &mut EventContext) + 'static,
+    ) -> Self {
+        self.right_click = Some(Rc::new(handler));
+        self
+    }
+
+    pub fn on_mouse_down_out(
+        mut self,
+        handler: impl Fn(Vector2F, &mut EventContext) + 'static,
+    ) -> Self {
+        self.mouse_down_out = Some(Rc::new(handler));
         self
     }
 
-    pub fn on_click(mut self, handler: impl FnMut(usize, &mut EventContext) + 'static) -> Self {
-        self.click_handler = Some(Box::new(handler));
+    pub fn on_right_mouse_down_out(
+        mut self,
+        handler: impl Fn(Vector2F, &mut EventContext) + 'static,
+    ) -> Self {
+        self.right_mouse_down_out = Some(Rc::new(handler));
         self
     }
 
-    pub fn on_drag(mut self, handler: impl FnMut(Vector2F, &mut EventContext) + 'static) -> Self {
-        self.drag_handler = Some(Box::new(handler));
+    pub fn on_drag(mut self, handler: impl Fn(Vector2F, &mut EventContext) + 'static) -> Self {
+        self.drag = Some(Rc::new(handler));
         self
     }
 
@@ -100,10 +142,27 @@ impl Element for MouseEventHandler {
         _: &mut Self::LayoutState,
         cx: &mut PaintContext,
     ) -> Self::PaintState {
-        if let Some(cursor_style) = self.cursor_style {
-            cx.scene
-                .push_cursor_style(self.hit_bounds(bounds), cursor_style);
+        if let Some(style) = self.cursor_style {
+            cx.scene.push_cursor_region(CursorRegion {
+                bounds: self.hit_bounds(bounds),
+                style,
+            });
         }
+
+        cx.scene.push_mouse_region(MouseRegion {
+            view_id: cx.current_view_id(),
+            discriminant: Some((self.tag, self.id)),
+            bounds: self.hit_bounds(bounds),
+            hover: None,
+            click: self.click.clone(),
+            mouse_down: self.mouse_down.clone(),
+            right_click: self.right_click.clone(),
+            right_mouse_down: self.right_mouse_down.clone(),
+            mouse_down_out: self.mouse_down_out.clone(),
+            right_mouse_down_out: self.right_mouse_down_out.clone(),
+            drag: self.drag.clone(),
+        });
+
         self.child.paint(bounds.origin(), visible_bounds, cx);
     }
 
@@ -111,81 +170,12 @@ impl Element for MouseEventHandler {
         &mut self,
         event: &Event,
         _: RectF,
-        visible_bounds: RectF,
+        _: RectF,
         _: &mut Self::LayoutState,
         _: &mut Self::PaintState,
         cx: &mut EventContext,
     ) -> bool {
-        let hit_bounds = self.hit_bounds(visible_bounds);
-        let mouse_down_handler = self.mouse_down_handler.as_mut();
-        let click_handler = self.click_handler.as_mut();
-        let drag_handler = self.drag_handler.as_mut();
-
-        let handled_in_child = self.child.dispatch_event(event, cx);
-
-        self.state.update(cx, |state, cx| match event {
-            Event::MouseMoved {
-                position,
-                left_mouse_down,
-            } => {
-                if !left_mouse_down {
-                    let mouse_in = hit_bounds.contains_point(*position);
-                    if state.hovered != mouse_in {
-                        state.hovered = mouse_in;
-                        cx.notify();
-                        return true;
-                    }
-                }
-                handled_in_child
-            }
-            Event::LeftMouseDown { position, .. } => {
-                if !handled_in_child && hit_bounds.contains_point(*position) {
-                    state.clicked = true;
-                    state.prev_drag_position = Some(*position);
-                    cx.notify();
-                    if let Some(handler) = mouse_down_handler {
-                        handler(cx);
-                    }
-                    true
-                } else {
-                    handled_in_child
-                }
-            }
-            Event::LeftMouseUp {
-                position,
-                click_count,
-                ..
-            } => {
-                state.prev_drag_position = None;
-                if !handled_in_child && state.clicked {
-                    state.clicked = false;
-                    cx.notify();
-                    if let Some(handler) = click_handler {
-                        if hit_bounds.contains_point(*position) {
-                            handler(*click_count, cx);
-                        }
-                    }
-                    true
-                } else {
-                    handled_in_child
-                }
-            }
-            Event::LeftMouseDragged { position, .. } => {
-                if !handled_in_child && state.clicked {
-                    let prev_drag_position = state.prev_drag_position.replace(*position);
-                    if let Some((handler, prev_position)) = drag_handler.zip(prev_drag_position) {
-                        let delta = *position - prev_position;
-                        if !delta.is_zero() {
-                            (handler)(delta, cx);
-                        }
-                    }
-                    true
-                } else {
-                    handled_in_child
-                }
-            }
-            _ => handled_in_child,
-        })
+        self.child.dispatch_event(event, cx)
     }
 
     fn debug(

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

@@ -1,16 +1,28 @@
+use serde_json::json;
+
 use crate::{
     geometry::{rect::RectF, vector::Vector2F},
-    DebugContext, Element, ElementBox, Event, EventContext, LayoutContext, PaintContext,
-    SizeConstraint,
+    json::ToJson,
+    DebugContext, Element, ElementBox, Event, EventContext, LayoutContext, MouseRegion,
+    PaintContext, SizeConstraint,
 };
 
 pub struct Overlay {
     child: ElementBox,
+    abs_position: Option<Vector2F>,
 }
 
 impl Overlay {
     pub fn new(child: ElementBox) -> Self {
-        Self { child }
+        Self {
+            child,
+            abs_position: None,
+        }
+    }
+
+    pub fn with_abs_position(mut self, position: Vector2F) -> Self {
+        self.abs_position = Some(position);
+        self
     }
 }
 
@@ -23,6 +35,11 @@ impl Element for Overlay {
         constraint: SizeConstraint,
         cx: &mut LayoutContext,
     ) -> (Vector2F, Self::LayoutState) {
+        let constraint = if self.abs_position.is_some() {
+            SizeConstraint::new(Vector2F::zero(), cx.window_size)
+        } else {
+            constraint
+        };
         let size = self.child.layout(constraint, cx);
         (Vector2F::zero(), size)
     }
@@ -34,9 +51,15 @@ impl Element for Overlay {
         size: &mut Self::LayoutState,
         cx: &mut PaintContext,
     ) {
-        let bounds = RectF::new(bounds.origin(), *size);
+        let origin = self.abs_position.unwrap_or(bounds.origin());
+        let visible_bounds = RectF::new(origin, *size);
         cx.scene.push_stacking_context(None);
-        self.child.paint(bounds.origin(), bounds, cx);
+        cx.scene.push_mouse_region(MouseRegion {
+            view_id: cx.current_view_id(),
+            bounds: visible_bounds,
+            ..Default::default()
+        });
+        self.child.paint(origin, visible_bounds, cx);
         cx.scene.pop_stacking_context();
     }
 
@@ -59,6 +82,10 @@ impl Element for Overlay {
         _: &Self::PaintState,
         cx: &DebugContext,
     ) -> serde_json::Value {
-        self.child.debug(cx)
+        json!({
+            "type": "Overlay",
+            "abs_position": self.abs_position.to_json(),
+            "child": self.child.debug(cx),
+        })
     }
 }

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

@@ -5,7 +5,7 @@ use crate::{
         vector::{vec2f, Vector2F},
     },
     json::{self, json},
-    ElementBox,
+    ElementBox, RenderContext, View,
 };
 use json::ToJson;
 use std::{cell::RefCell, cmp, ops::Range, rc::Rc};
@@ -41,27 +41,37 @@ pub struct LayoutState {
     items: Vec<ElementBox>,
 }
 
-pub struct UniformList<F>
-where
-    F: Fn(Range<usize>, &mut Vec<ElementBox>, &mut LayoutContext),
-{
+pub struct UniformList {
     state: UniformListState,
     item_count: usize,
-    append_items: F,
+    append_items: Box<dyn Fn(Range<usize>, &mut Vec<ElementBox>, &mut LayoutContext)>,
     padding_top: f32,
     padding_bottom: f32,
     get_width_from_item: Option<usize>,
 }
 
-impl<F> UniformList<F>
-where
-    F: Fn(Range<usize>, &mut Vec<ElementBox>, &mut LayoutContext),
-{
-    pub fn new(state: UniformListState, item_count: usize, append_items: F) -> Self {
+impl UniformList {
+    pub fn new<F, V>(
+        state: UniformListState,
+        item_count: usize,
+        cx: &mut RenderContext<V>,
+        append_items: F,
+    ) -> Self
+    where
+        V: View,
+        F: 'static + Fn(&mut V, Range<usize>, &mut Vec<ElementBox>, &mut RenderContext<V>),
+    {
+        let handle = cx.handle();
         Self {
             state,
             item_count,
-            append_items,
+            append_items: Box::new(move |range, items, cx| {
+                if let Some(handle) = handle.upgrade(cx) {
+                    cx.render(&handle, |view, cx| {
+                        append_items(view, range, items, cx);
+                    });
+                }
+            }),
             padding_top: 0.,
             padding_bottom: 0.,
             get_width_from_item: None,
@@ -144,10 +154,7 @@ where
     }
 }
 
-impl<F> Element for UniformList<F>
-where
-    F: Fn(Range<usize>, &mut Vec<ElementBox>, &mut LayoutContext),
-{
+impl Element for UniformList {
     type LayoutState = LayoutState;
     type PaintState = ();
 
@@ -162,40 +169,51 @@ where
             );
         }
 
+        let no_items = (
+            constraint.min,
+            LayoutState {
+                item_height: 0.,
+                scroll_max: 0.,
+                items: Default::default(),
+            },
+        );
+
         if self.item_count == 0 {
-            return (
-                constraint.min,
-                LayoutState {
-                    item_height: 0.,
-                    scroll_max: 0.,
-                    items: Default::default(),
-                },
-            );
+            return no_items;
         }
 
         let mut items = Vec::new();
         let mut size = constraint.max;
         let mut item_size;
         let sample_item_ix;
-        let mut sample_item;
+        let sample_item;
         if let Some(sample_ix) = self.get_width_from_item {
             (self.append_items)(sample_ix..sample_ix + 1, &mut items, cx);
             sample_item_ix = sample_ix;
-            sample_item = items.pop().unwrap();
-            item_size = sample_item.layout(constraint, cx);
-            size.set_x(item_size.x());
+
+            if let Some(mut item) = items.pop() {
+                item_size = item.layout(constraint, cx);
+                size.set_x(item_size.x());
+                sample_item = item;
+            } else {
+                return no_items;
+            }
         } else {
             (self.append_items)(0..1, &mut items, cx);
             sample_item_ix = 0;
-            sample_item = items.pop().unwrap();
-            item_size = sample_item.layout(
-                SizeConstraint::new(
-                    vec2f(constraint.max.x(), 0.0),
-                    vec2f(constraint.max.x(), f32::INFINITY),
-                ),
-                cx,
-            );
-            item_size.set_x(size.x());
+            if let Some(mut item) = items.pop() {
+                item_size = item.layout(
+                    SizeConstraint::new(
+                        vec2f(constraint.max.x(), 0.0),
+                        vec2f(constraint.max.x(), f32::INFINITY),
+                    ),
+                    cx,
+                );
+                item_size.set_x(size.x());
+                sample_item = item
+            } else {
+                return no_items;
+            }
         }
 
         let item_constraint = SizeConstraint {

crates/gpui/src/gpui.rs 🔗

@@ -16,7 +16,7 @@ pub mod fonts;
 pub mod geometry;
 mod presenter;
 mod scene;
-pub use scene::{Border, Quad, Scene};
+pub use scene::{Border, CursorRegion, MouseRegion, MouseRegionId, Quad, Scene};
 pub mod text_layout;
 pub use text_layout::TextLayoutCache;
 mod util;

crates/gpui/src/keymap.rs 🔗

@@ -30,9 +30,9 @@ pub struct Keymap {
 }
 
 pub struct Binding {
-    keystrokes: Vec<Keystroke>,
+    keystrokes: SmallVec<[Keystroke; 2]>,
     action: Box<dyn Action>,
-    context: Option<ContextPredicate>,
+    context_predicate: Option<ContextPredicate>,
 }
 
 #[derive(Clone, Debug, Eq, PartialEq)]
@@ -146,7 +146,11 @@ impl Matcher {
         let mut retain_pending = false;
         for binding in self.keymap.bindings.iter().rev() {
             if binding.keystrokes.starts_with(&pending.keystrokes)
-                && binding.context.as_ref().map(|c| c.eval(cx)).unwrap_or(true)
+                && binding
+                    .context_predicate
+                    .as_ref()
+                    .map(|c| c.eval(cx))
+                    .unwrap_or(true)
             {
                 if binding.keystrokes.len() == pending.keystrokes.len() {
                     self.pending.remove(&view_id);
@@ -165,6 +169,24 @@ impl Matcher {
             MatchResult::None
         }
     }
+
+    pub fn keystrokes_for_action(
+        &self,
+        action: &dyn Action,
+        cx: &Context,
+    ) -> Option<SmallVec<[Keystroke; 2]>> {
+        for binding in self.keymap.bindings.iter().rev() {
+            if binding.action.id() == action.id()
+                && binding
+                    .context_predicate
+                    .as_ref()
+                    .map_or(true, |predicate| predicate.eval(cx))
+            {
+                return Some(binding.keystrokes.clone());
+            }
+        }
+        None
+    }
 }
 
 impl Default for Matcher {
@@ -236,7 +258,7 @@ impl Binding {
         Ok(Self {
             keystrokes,
             action,
-            context,
+            context_predicate: context,
         })
     }
 
@@ -289,6 +311,34 @@ impl Keystroke {
     }
 }
 
+impl std::fmt::Display for Keystroke {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        if self.ctrl {
+            write!(f, "{}", "^")?;
+        }
+        if self.alt {
+            write!(f, "{}", "⎇")?;
+        }
+        if self.cmd {
+            write!(f, "{}", "⌘")?;
+        }
+        if self.shift {
+            write!(f, "{}", "⇧")?;
+        }
+        let key = match self.key.as_str() {
+            "backspace" => "⌫",
+            "up" => "↑",
+            "down" => "↓",
+            "left" => "←",
+            "right" => "→",
+            "tab" => "⇥",
+            "escape" => "⎋",
+            key => key,
+        };
+        write!(f, "{}", key)
+    }
+}
+
 impl Context {
     pub fn extend(&mut self, other: &Context) {
         for v in &other.set {

crates/gpui/src/platform/event.rs 🔗

@@ -43,6 +43,7 @@ pub enum Event {
     },
     RightMouseUp {
         position: Vector2F,
+        click_count: usize,
     },
     NavigateMouseDown {
         position: Vector2F,
@@ -72,7 +73,7 @@ impl Event {
             | Event::LeftMouseUp { position, .. }
             | Event::LeftMouseDragged { position }
             | Event::RightMouseDown { position, .. }
-            | Event::RightMouseUp { position }
+            | Event::RightMouseUp { position, .. }
             | Event::NavigateMouseDown { position, .. }
             | Event::NavigateMouseUp { position, .. }
             | Event::MouseMoved { position, .. } => Some(*position),

crates/gpui/src/platform/mac/event.rs 🔗

@@ -178,6 +178,7 @@ impl Event {
                     native_event.locationInWindow().x as f32,
                     window_height - native_event.locationInWindow().y as f32,
                 ),
+                click_count: native_event.clickCount() as usize,
             }),
             NSEventType::NSOtherMouseDown => {
                 let direction = match native_event.buttonNumber() {

crates/gpui/src/presenter.rs 🔗

@@ -4,16 +4,21 @@ use crate::{
     font_cache::FontCache,
     geometry::rect::RectF,
     json::{self, ToJson},
+    keymap::Keystroke,
     platform::{CursorStyle, Event},
+    scene::CursorRegion,
     text_layout::TextLayoutCache,
-    Action, AnyModelHandle, AnyViewHandle, AnyWeakModelHandle, AssetCache, ElementBox,
-    ElementStateContext, Entity, FontSystem, ModelHandle, ReadModel, ReadView, Scene,
-    UpgradeModelHandle, UpgradeViewHandle, View, ViewHandle, WeakModelHandle, WeakViewHandle,
+    Action, AnyModelHandle, AnyViewHandle, AnyWeakModelHandle, AssetCache, ElementBox, Entity,
+    FontSystem, ModelHandle, MouseRegion, MouseRegionId, ReadModel, ReadView, RenderContext,
+    RenderParams, Scene, UpgradeModelHandle, UpgradeViewHandle, View, ViewHandle, WeakModelHandle,
+    WeakViewHandle,
 };
 use pathfinder_geometry::vector::{vec2f, Vector2F};
 use serde_json::json;
+use smallvec::SmallVec;
 use std::{
     collections::{HashMap, HashSet},
+    marker::PhantomData,
     ops::{Deref, DerefMut},
     sync::Arc,
 };
@@ -22,11 +27,16 @@ pub struct Presenter {
     window_id: usize,
     pub(crate) rendered_views: HashMap<usize, ElementBox>,
     parents: HashMap<usize, usize>,
-    cursor_styles: Vec<(RectF, CursorStyle)>,
+    cursor_regions: Vec<CursorRegion>,
+    mouse_regions: Vec<(MouseRegion, usize)>,
     font_cache: Arc<FontCache>,
     text_layout_cache: TextLayoutCache,
     asset_cache: Arc<AssetCache>,
     last_mouse_moved_event: Option<Event>,
+    hovered_region_ids: HashSet<MouseRegionId>,
+    clicked_region: Option<MouseRegion>,
+    right_clicked_region: Option<MouseRegion>,
+    prev_drag_position: Option<Vector2F>,
     titlebar_height: f32,
 }
 
@@ -43,32 +53,35 @@ impl Presenter {
             window_id,
             rendered_views: cx.render_views(window_id, titlebar_height),
             parents: HashMap::new(),
-            cursor_styles: Default::default(),
+            cursor_regions: Default::default(),
+            mouse_regions: Default::default(),
             font_cache,
             text_layout_cache,
             asset_cache,
             last_mouse_moved_event: None,
+            hovered_region_ids: Default::default(),
+            clicked_region: None,
+            right_clicked_region: None,
+            prev_drag_position: None,
             titlebar_height,
         }
     }
 
     pub fn dispatch_path(&self, app: &AppContext) -> Vec<usize> {
+        let mut path = Vec::new();
         if let Some(view_id) = app.focused_view_id(self.window_id) {
-            self.dispatch_path_from(view_id)
-        } else {
-            Vec::new()
+            self.compute_dispatch_path_from(view_id, &mut path)
         }
+        path
     }
 
-    pub(crate) fn dispatch_path_from(&self, mut view_id: usize) -> Vec<usize> {
-        let mut path = Vec::new();
+    pub(crate) fn compute_dispatch_path_from(&self, mut view_id: usize, path: &mut Vec<usize>) {
         path.push(view_id);
         while let Some(parent_id) = self.parents.get(&view_id).copied() {
             path.push(parent_id);
             view_id = parent_id;
         }
         path.reverse();
-        path
     }
 
     pub fn invalidate(
@@ -85,8 +98,19 @@ impl Presenter {
         for view_id in &invalidation.updated {
             self.rendered_views.insert(
                 *view_id,
-                cx.render_view(self.window_id, *view_id, self.titlebar_height, false)
-                    .unwrap(),
+                cx.render_view(RenderParams {
+                    window_id: self.window_id,
+                    view_id: *view_id,
+                    titlebar_height: self.titlebar_height,
+                    hovered_region_ids: self.hovered_region_ids.clone(),
+                    clicked_region_id: self.clicked_region.as_ref().and_then(MouseRegion::id),
+                    right_clicked_region_id: self
+                        .right_clicked_region
+                        .as_ref()
+                        .and_then(MouseRegion::id),
+                    refreshing: false,
+                })
+                .unwrap(),
             );
         }
     }
@@ -96,7 +120,18 @@ impl Presenter {
         for (view_id, view) in &mut self.rendered_views {
             if !invalidation.updated.contains(view_id) {
                 *view = cx
-                    .render_view(self.window_id, *view_id, self.titlebar_height, true)
+                    .render_view(RenderParams {
+                        window_id: self.window_id,
+                        view_id: *view_id,
+                        titlebar_height: self.titlebar_height,
+                        hovered_region_ids: self.hovered_region_ids.clone(),
+                        clicked_region_id: self.clicked_region.as_ref().and_then(MouseRegion::id),
+                        right_clicked_region_id: self
+                            .right_clicked_region
+                            .as_ref()
+                            .and_then(MouseRegion::id),
+                        refreshing: true,
+                    })
                     .unwrap();
             }
         }
@@ -120,7 +155,8 @@ impl Presenter {
                 RectF::new(Vector2F::zero(), window_size),
             );
             self.text_layout_cache.finish_frame();
-            self.cursor_styles = scene.cursor_styles();
+            self.cursor_regions = scene.cursor_regions();
+            self.mouse_regions = scene.mouse_regions();
 
             if cx.window_is_active(self.window_id) {
                 if let Some(event) = self.last_mouse_moved_event.clone() {
@@ -134,27 +170,34 @@ impl Presenter {
         scene
     }
 
-    fn layout(&mut self, size: Vector2F, refreshing: bool, cx: &mut MutableAppContext) {
+    fn layout(&mut self, window_size: Vector2F, refreshing: bool, cx: &mut MutableAppContext) {
         if let Some(root_view_id) = cx.root_view_id(self.window_id) {
-            self.build_layout_context(refreshing, cx)
-                .layout(root_view_id, SizeConstraint::strict(size));
+            self.build_layout_context(window_size, refreshing, cx)
+                .layout(root_view_id, SizeConstraint::strict(window_size));
         }
     }
 
     pub fn build_layout_context<'a>(
         &'a mut self,
+        window_size: Vector2F,
         refreshing: bool,
         cx: &'a mut MutableAppContext,
     ) -> LayoutContext<'a> {
         LayoutContext {
+            window_id: self.window_id,
             rendered_views: &mut self.rendered_views,
             parents: &mut self.parents,
-            refreshing,
             font_cache: &self.font_cache,
             font_system: cx.platform().fonts(),
             text_layout_cache: &self.text_layout_cache,
             asset_cache: &self.asset_cache,
             view_stack: Vec::new(),
+            refreshing,
+            hovered_region_ids: self.hovered_region_ids.clone(),
+            clicked_region_id: self.clicked_region.as_ref().and_then(MouseRegion::id),
+            right_clicked_region_id: self.right_clicked_region.as_ref().and_then(MouseRegion::id),
+            titlebar_height: self.titlebar_height,
+            window_size,
             app: cx,
         }
     }
@@ -169,13 +212,80 @@ impl Presenter {
             font_cache: &self.font_cache,
             text_layout_cache: &self.text_layout_cache,
             rendered_views: &mut self.rendered_views,
+            view_stack: Vec::new(),
             app: cx,
         }
     }
 
     pub fn dispatch_event(&mut self, event: Event, cx: &mut MutableAppContext) {
         if let Some(root_view_id) = cx.root_view_id(self.window_id) {
+            let mut invalidated_views = Vec::new();
+            let mut hovered_regions = Vec::new();
+            let mut unhovered_regions = Vec::new();
+            let mut mouse_down_out_handlers = Vec::new();
+            let mut mouse_down_region = None;
+            let mut clicked_region = None;
+            let mut right_mouse_down_region = None;
+            let mut right_clicked_region = None;
+            let mut dragged_region = None;
+
             match event {
+                Event::LeftMouseDown { position, .. } => {
+                    let mut hit = false;
+                    for (region, _) in self.mouse_regions.iter().rev() {
+                        if region.bounds.contains_point(position) {
+                            if !hit {
+                                hit = true;
+                                invalidated_views.push(region.view_id);
+                                mouse_down_region = Some((region.clone(), position));
+                                self.clicked_region = Some(region.clone());
+                                self.prev_drag_position = Some(position);
+                            }
+                        } else if let Some(handler) = region.mouse_down_out.clone() {
+                            mouse_down_out_handlers.push((handler, region.view_id, position));
+                        }
+                    }
+                }
+                Event::LeftMouseUp {
+                    position,
+                    click_count,
+                    ..
+                } => {
+                    self.prev_drag_position.take();
+                    if let Some(region) = self.clicked_region.take() {
+                        invalidated_views.push(region.view_id);
+                        if region.bounds.contains_point(position) {
+                            clicked_region = Some((region, position, click_count));
+                        }
+                    }
+                }
+                Event::RightMouseDown { position, .. } => {
+                    let mut hit = false;
+                    for (region, _) in self.mouse_regions.iter().rev() {
+                        if region.bounds.contains_point(position) {
+                            if !hit {
+                                hit = true;
+                                invalidated_views.push(region.view_id);
+                                right_mouse_down_region = Some((region.clone(), position));
+                                self.right_clicked_region = Some(region.clone());
+                            }
+                        } else if let Some(handler) = region.right_mouse_down_out.clone() {
+                            mouse_down_out_handlers.push((handler, region.view_id, position));
+                        }
+                    }
+                }
+                Event::RightMouseUp {
+                    position,
+                    click_count,
+                    ..
+                } => {
+                    if let Some(region) = self.right_clicked_region.take() {
+                        invalidated_views.push(region.view_id);
+                        if region.bounds.contains_point(position) {
+                            right_clicked_region = Some((region, position, click_count));
+                        }
+                    }
+                }
                 Event::MouseMoved {
                     position,
                     left_mouse_down,
@@ -184,16 +294,50 @@ impl Presenter {
 
                     if !left_mouse_down {
                         let mut style_to_assign = CursorStyle::Arrow;
-                        for (bounds, style) in self.cursor_styles.iter().rev() {
-                            if bounds.contains_point(position) {
-                                style_to_assign = *style;
+                        for region in self.cursor_regions.iter().rev() {
+                            if region.bounds.contains_point(position) {
+                                style_to_assign = region.style;
                                 break;
                             }
                         }
                         cx.platform().set_cursor_style(style_to_assign);
+
+                        let mut hover_depth = None;
+                        for (region, depth) in self.mouse_regions.iter().rev() {
+                            if region.bounds.contains_point(position)
+                                && hover_depth.map_or(true, |hover_depth| hover_depth == *depth)
+                            {
+                                hover_depth = Some(*depth);
+                                if let Some(region_id) = region.id() {
+                                    if !self.hovered_region_ids.contains(&region_id) {
+                                        invalidated_views.push(region.view_id);
+                                        hovered_regions.push(region.clone());
+                                        self.hovered_region_ids.insert(region_id);
+                                    }
+                                }
+                            } else {
+                                if let Some(region_id) = region.id() {
+                                    if self.hovered_region_ids.contains(&region_id) {
+                                        invalidated_views.push(region.view_id);
+                                        unhovered_regions.push(region.clone());
+                                        self.hovered_region_ids.remove(&region_id);
+                                    }
+                                }
+                            }
+                        }
                     }
                 }
                 Event::LeftMouseDragged { position } => {
+                    if let Some((clicked_region, prev_drag_position)) = self
+                        .clicked_region
+                        .as_ref()
+                        .zip(self.prev_drag_position.as_mut())
+                    {
+                        dragged_region =
+                            Some((clicked_region.clone(), position - *prev_drag_position));
+                        *prev_drag_position = position;
+                    }
+
                     self.last_mouse_moved_event = Some(Event::MouseMoved {
                         position,
                         left_mouse_down: true,
@@ -203,16 +347,92 @@ impl Presenter {
             }
 
             let mut event_cx = self.build_event_context(cx);
-            event_cx.dispatch_event(root_view_id, &event);
+            let mut handled = false;
+            for unhovered_region in unhovered_regions {
+                handled = true;
+                if let Some(hover_callback) = unhovered_region.hover {
+                    event_cx.with_current_view(unhovered_region.view_id, |event_cx| {
+                        hover_callback(false, event_cx);
+                    })
+                }
+            }
 
-            let invalidated_views = event_cx.invalidated_views;
+            for hovered_region in hovered_regions {
+                handled = true;
+                if let Some(hover_callback) = hovered_region.hover {
+                    event_cx.with_current_view(hovered_region.view_id, |event_cx| {
+                        hover_callback(true, event_cx);
+                    })
+                }
+            }
+
+            for (handler, view_id, position) in mouse_down_out_handlers {
+                event_cx.with_current_view(view_id, |event_cx| handler(position, event_cx))
+            }
+
+            if let Some((mouse_down_region, position)) = mouse_down_region {
+                handled = true;
+                if let Some(mouse_down_callback) = mouse_down_region.mouse_down {
+                    event_cx.with_current_view(mouse_down_region.view_id, |event_cx| {
+                        mouse_down_callback(position, event_cx);
+                    })
+                }
+            }
+
+            if let Some((clicked_region, position, click_count)) = clicked_region {
+                handled = true;
+                if let Some(click_callback) = clicked_region.click {
+                    event_cx.with_current_view(clicked_region.view_id, |event_cx| {
+                        click_callback(position, click_count, event_cx);
+                    })
+                }
+            }
+
+            if let Some((right_mouse_down_region, position)) = right_mouse_down_region {
+                handled = true;
+                if let Some(right_mouse_down_callback) = right_mouse_down_region.right_mouse_down {
+                    event_cx.with_current_view(right_mouse_down_region.view_id, |event_cx| {
+                        right_mouse_down_callback(position, event_cx);
+                    })
+                }
+            }
+
+            if let Some((right_clicked_region, position, click_count)) = right_clicked_region {
+                handled = true;
+                if let Some(right_click_callback) = right_clicked_region.right_click {
+                    event_cx.with_current_view(right_clicked_region.view_id, |event_cx| {
+                        right_click_callback(position, click_count, event_cx);
+                    })
+                }
+            }
+
+            if let Some((dragged_region, delta)) = dragged_region {
+                handled = true;
+                if let Some(drag_callback) = dragged_region.drag {
+                    event_cx.with_current_view(dragged_region.view_id, |event_cx| {
+                        drag_callback(delta, event_cx);
+                    })
+                }
+            }
+
+            if !handled {
+                event_cx.dispatch_event(root_view_id, &event);
+            }
+
+            invalidated_views.extend(event_cx.invalidated_views);
             let dispatch_directives = event_cx.dispatched_actions;
 
             for view_id in invalidated_views {
                 cx.notify_view(self.window_id, view_id);
             }
+
+            let mut dispatch_path = Vec::new();
             for directive in dispatch_directives {
-                cx.dispatch_action_any(self.window_id, &directive.path, directive.action.as_ref());
+                dispatch_path.clear();
+                if let Some(view_id) = directive.dispatcher_view_id {
+                    self.compute_dispatch_path_from(view_id, &mut dispatch_path);
+                }
+                cx.dispatch_action_any(self.window_id, &dispatch_path, directive.action.as_ref());
             }
         }
     }
@@ -250,23 +470,37 @@ impl Presenter {
 }
 
 pub struct DispatchDirective {
-    pub path: Vec<usize>,
+    pub dispatcher_view_id: Option<usize>,
     pub action: Box<dyn Action>,
 }
 
 pub struct LayoutContext<'a> {
+    window_id: usize,
     rendered_views: &'a mut HashMap<usize, ElementBox>,
     parents: &'a mut HashMap<usize, usize>,
     view_stack: Vec<usize>,
-    pub refreshing: bool,
     pub font_cache: &'a Arc<FontCache>,
     pub font_system: Arc<dyn FontSystem>,
     pub text_layout_cache: &'a TextLayoutCache,
     pub asset_cache: &'a AssetCache,
     pub app: &'a mut MutableAppContext,
+    pub refreshing: bool,
+    pub window_size: Vector2F,
+    titlebar_height: f32,
+    hovered_region_ids: HashSet<MouseRegionId>,
+    clicked_region_id: Option<MouseRegionId>,
+    right_clicked_region_id: Option<MouseRegionId>,
 }
 
 impl<'a> LayoutContext<'a> {
+    pub(crate) fn keystrokes_for_action(
+        &self,
+        action: &dyn Action,
+    ) -> Option<SmallVec<[Keystroke; 2]>> {
+        self.app
+            .keystrokes_for_action(self.window_id, &self.view_stack, action)
+    }
+
     fn layout(&mut self, view_id: usize, constraint: SizeConstraint) -> Vector2F {
         if let Some(parent_id) = self.view_stack.last() {
             self.parents.insert(view_id, *parent_id);
@@ -278,6 +512,27 @@ impl<'a> LayoutContext<'a> {
         self.view_stack.pop();
         size
     }
+
+    pub fn render<F, V, T>(&mut self, handle: &ViewHandle<V>, f: F) -> T
+    where
+        F: FnOnce(&mut V, &mut RenderContext<V>) -> T,
+        V: View,
+    {
+        handle.update(self.app, |view, cx| {
+            let mut render_cx = RenderContext {
+                app: cx,
+                window_id: handle.window_id(),
+                view_id: handle.id(),
+                view_type: PhantomData,
+                titlebar_height: self.titlebar_height,
+                hovered_region_ids: self.hovered_region_ids.clone(),
+                clicked_region_id: self.clicked_region_id,
+                right_clicked_region_id: self.right_clicked_region_id,
+                refreshing: self.refreshing,
+            };
+            f(view, &mut render_cx)
+        })
+    }
 }
 
 impl<'a> Deref for LayoutContext<'a> {
@@ -333,14 +588,9 @@ impl<'a> UpgradeViewHandle for LayoutContext<'a> {
     }
 }
 
-impl<'a> ElementStateContext for LayoutContext<'a> {
-    fn current_view_id(&self) -> usize {
-        *self.view_stack.last().unwrap()
-    }
-}
-
 pub struct PaintContext<'a> {
     rendered_views: &'a mut HashMap<usize, ElementBox>,
+    view_stack: Vec<usize>,
     pub scene: &'a mut Scene,
     pub font_cache: &'a FontCache,
     pub text_layout_cache: &'a TextLayoutCache,
@@ -350,10 +600,16 @@ pub struct PaintContext<'a> {
 impl<'a> PaintContext<'a> {
     fn paint(&mut self, view_id: usize, origin: Vector2F, visible_bounds: RectF) {
         if let Some(mut tree) = self.rendered_views.remove(&view_id) {
+            self.view_stack.push(view_id);
             tree.paint(origin, visible_bounds, self);
             self.rendered_views.insert(view_id, tree);
+            self.view_stack.pop();
         }
     }
+
+    pub fn current_view_id(&self) -> usize {
+        *self.view_stack.last().unwrap()
+    }
 }
 
 impl<'a> Deref for PaintContext<'a> {
@@ -378,9 +634,8 @@ pub struct EventContext<'a> {
 impl<'a> EventContext<'a> {
     fn dispatch_event(&mut self, view_id: usize, event: &Event) -> bool {
         if let Some(mut element) = self.rendered_views.remove(&view_id) {
-            self.view_stack.push(view_id);
-            let result = element.dispatch_event(event, self);
-            self.view_stack.pop();
+            let result =
+                self.with_current_view(view_id, |this| element.dispatch_event(event, this));
             self.rendered_views.insert(view_id, element);
             result
         } else {
@@ -388,9 +643,19 @@ impl<'a> EventContext<'a> {
         }
     }
 
+    fn with_current_view<F, T>(&mut self, view_id: usize, f: F) -> T
+    where
+        F: FnOnce(&mut Self) -> T,
+    {
+        self.view_stack.push(view_id);
+        let result = f(self);
+        self.view_stack.pop();
+        result
+    }
+
     pub fn dispatch_any_action(&mut self, action: Box<dyn Action>) {
         self.dispatched_actions.push(DispatchDirective {
-            path: self.view_stack.clone(),
+            dispatcher_view_id: self.view_stack.last().copied(),
             action,
         });
     }
@@ -521,6 +786,15 @@ impl SizeConstraint {
     }
 }
 
+impl Default for SizeConstraint {
+    fn default() -> Self {
+        SizeConstraint {
+            min: Vector2F::zero(),
+            max: Vector2F::splat(f32::INFINITY),
+        }
+    }
+}
+
 impl ToJson for SizeConstraint {
     fn to_json(&self) -> serde_json::Value {
         json!({

crates/gpui/src/scene.rs 🔗

@@ -1,6 +1,6 @@
 use serde::Deserialize;
 use serde_json::json;
-use std::{borrow::Cow, sync::Arc};
+use std::{any::TypeId, borrow::Cow, rc::Rc, sync::Arc};
 
 use crate::{
     color::Color,
@@ -8,7 +8,7 @@ use crate::{
     geometry::{rect::RectF, vector::Vector2F},
     json::ToJson,
     platform::CursorStyle,
-    ImageData,
+    EventContext, ImageData,
 };
 
 pub struct Scene {
@@ -20,6 +20,7 @@ pub struct Scene {
 struct StackingContext {
     layers: Vec<Layer>,
     active_layer_stack: Vec<usize>,
+    depth: usize,
 }
 
 #[derive(Default)]
@@ -33,7 +34,35 @@ pub struct Layer {
     image_glyphs: Vec<ImageGlyph>,
     icons: Vec<Icon>,
     paths: Vec<Path>,
-    cursor_styles: Vec<(RectF, CursorStyle)>,
+    cursor_regions: Vec<CursorRegion>,
+    mouse_regions: Vec<MouseRegion>,
+}
+
+#[derive(Copy, Clone)]
+pub struct CursorRegion {
+    pub bounds: RectF,
+    pub style: CursorStyle,
+}
+
+#[derive(Clone, Default)]
+pub struct MouseRegion {
+    pub view_id: usize,
+    pub discriminant: Option<(TypeId, usize)>,
+    pub bounds: RectF,
+    pub hover: Option<Rc<dyn Fn(bool, &mut EventContext)>>,
+    pub mouse_down: Option<Rc<dyn Fn(Vector2F, &mut EventContext)>>,
+    pub click: Option<Rc<dyn Fn(Vector2F, usize, &mut EventContext)>>,
+    pub right_mouse_down: Option<Rc<dyn Fn(Vector2F, &mut EventContext)>>,
+    pub right_click: Option<Rc<dyn Fn(Vector2F, usize, &mut EventContext)>>,
+    pub drag: Option<Rc<dyn Fn(Vector2F, &mut EventContext)>>,
+    pub mouse_down_out: Option<Rc<dyn Fn(Vector2F, &mut EventContext)>>,
+    pub right_mouse_down_out: Option<Rc<dyn Fn(Vector2F, &mut EventContext)>>,
+}
+
+#[derive(Copy, Clone, Eq, PartialEq, Hash)]
+pub struct MouseRegionId {
+    pub view_id: usize,
+    pub discriminant: (TypeId, usize),
 }
 
 #[derive(Default, Debug)]
@@ -159,7 +188,7 @@ pub struct Image {
 
 impl Scene {
     pub fn new(scale_factor: f32) -> Self {
-        let stacking_context = StackingContext::new(None);
+        let stacking_context = StackingContext::new(0, None);
         Scene {
             scale_factor,
             stacking_contexts: vec![stacking_context],
@@ -175,18 +204,32 @@ impl Scene {
         self.stacking_contexts.iter().flat_map(|s| &s.layers)
     }
 
-    pub fn cursor_styles(&self) -> Vec<(RectF, CursorStyle)> {
+    pub fn cursor_regions(&self) -> Vec<CursorRegion> {
         self.layers()
-            .flat_map(|layer| &layer.cursor_styles)
+            .flat_map(|layer| &layer.cursor_regions)
             .copied()
             .collect()
     }
 
+    pub fn mouse_regions(&self) -> Vec<(MouseRegion, usize)> {
+        let mut regions = Vec::new();
+        for stacking_context in self.stacking_contexts.iter() {
+            for layer in &stacking_context.layers {
+                for mouse_region in &layer.mouse_regions {
+                    regions.push((mouse_region.clone(), stacking_context.depth));
+                }
+            }
+        }
+        regions.sort_by_key(|(_, depth)| *depth);
+        regions
+    }
+
     pub fn push_stacking_context(&mut self, clip_bounds: Option<RectF>) {
+        let depth = self.active_stacking_context().depth + 1;
         self.active_stacking_context_stack
             .push(self.stacking_contexts.len());
         self.stacking_contexts
-            .push(StackingContext::new(clip_bounds))
+            .push(StackingContext::new(depth, clip_bounds))
     }
 
     pub fn pop_stacking_context(&mut self) {
@@ -206,8 +249,12 @@ impl Scene {
         self.active_layer().push_quad(quad)
     }
 
-    pub fn push_cursor_style(&mut self, bounds: RectF, style: CursorStyle) {
-        self.active_layer().push_cursor_style(bounds, style);
+    pub fn push_cursor_region(&mut self, region: CursorRegion) {
+        self.active_layer().push_cursor_region(region);
+    }
+
+    pub fn push_mouse_region(&mut self, region: MouseRegion) {
+        self.active_layer().push_mouse_region(region);
     }
 
     pub fn push_image(&mut self, image: Image) {
@@ -249,10 +296,11 @@ impl Scene {
 }
 
 impl StackingContext {
-    fn new(clip_bounds: Option<RectF>) -> Self {
+    fn new(depth: usize, clip_bounds: Option<RectF>) -> Self {
         Self {
             layers: vec![Layer::new(clip_bounds)],
             active_layer_stack: vec![0],
+            depth,
         }
     }
 
@@ -298,7 +346,8 @@ impl Layer {
             glyphs: Default::default(),
             icons: Default::default(),
             paths: Default::default(),
-            cursor_styles: Default::default(),
+            cursor_regions: Default::default(),
+            mouse_regions: Default::default(),
         }
     }
 
@@ -316,10 +365,24 @@ impl Layer {
         self.quads.as_slice()
     }
 
-    fn push_cursor_style(&mut self, bounds: RectF, style: CursorStyle) {
-        if let Some(bounds) = bounds.intersection(self.clip_bounds.unwrap_or(bounds)) {
+    fn push_cursor_region(&mut self, region: CursorRegion) {
+        if let Some(bounds) = region
+            .bounds
+            .intersection(self.clip_bounds.unwrap_or(region.bounds))
+        {
             if can_draw(bounds) {
-                self.cursor_styles.push((bounds, style));
+                self.cursor_regions.push(region);
+            }
+        }
+    }
+
+    fn push_mouse_region(&mut self, region: MouseRegion) {
+        if let Some(bounds) = region
+            .bounds
+            .intersection(self.clip_bounds.unwrap_or(region.bounds))
+        {
+            if can_draw(bounds) {
+                self.mouse_regions.push(region);
             }
         }
     }
@@ -484,6 +547,15 @@ impl ToJson for Border {
     }
 }
 
+impl MouseRegion {
+    pub fn id(&self) -> Option<MouseRegionId> {
+        self.discriminant.map(|discriminant| MouseRegionId {
+            view_id: self.view_id,
+            discriminant,
+        })
+    }
+}
+
 fn can_draw(bounds: RectF) -> bool {
     let size = bounds.size();
     size.x() > 0. && size.y() > 0.

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

@@ -119,11 +119,10 @@ impl View for Select {
                 .with_style(style.header)
                 .boxed()
             })
-            .on_click(move |_, cx| cx.dispatch_action(ToggleSelect))
+            .on_click(move |_, _, cx| cx.dispatch_action(ToggleSelect))
             .boxed(),
         );
         if self.is_open {
-            let handle = self.handle.clone();
             result.add_child(
                 Overlay::new(
                     Container::new(
@@ -131,9 +130,8 @@ impl View for Select {
                             UniformList::new(
                                 self.list_state.clone(),
                                 self.item_count,
-                                move |mut range, items, cx| {
-                                    let handle = handle.upgrade(cx).unwrap();
-                                    let this = handle.read(cx);
+                                cx,
+                                move |this, mut range, items, cx| {
                                     let selected_item_ix = this.selected_item_ix;
                                     range.end = range.end.min(this.item_count);
                                     items.extend(range.map(|ix| {
@@ -141,7 +139,7 @@ impl View for Select {
                                             ix,
                                             cx,
                                             |mouse_state, cx| {
-                                                (handle.read(cx).render_item)(
+                                                (this.render_item)(
                                                     ix,
                                                     if ix == selected_item_ix {
                                                         ItemType::Selected
@@ -153,7 +151,9 @@ impl View for Select {
                                                 )
                                             },
                                         )
-                                        .on_click(move |_, cx| cx.dispatch_action(SelectItem(ix)))
+                                        .on_click(move |_, _, cx| {
+                                            cx.dispatch_action(SelectItem(ix))
+                                        })
                                         .boxed()
                                     }))
                                 },

crates/menu/Cargo.toml 🔗

@@ -0,0 +1,11 @@
+[package]
+name = "menu"
+version = "0.1.0"
+edition = "2021"
+
+[lib]
+path = "src/menu.rs"
+doctest = false
+
+[dependencies]
+gpui = { path = "../gpui" }

crates/outline/src/outline.rs 🔗

@@ -4,8 +4,8 @@ use editor::{
 };
 use fuzzy::StringMatch;
 use gpui::{
-    actions, elements::*, geometry::vector::Vector2F, AppContext, Entity, MutableAppContext,
-    RenderContext, Task, View, ViewContext, ViewHandle,
+    actions, elements::*, geometry::vector::Vector2F, AppContext, Entity, MouseState,
+    MutableAppContext, RenderContext, Task, View, ViewContext, ViewHandle,
 };
 use language::Outline;
 use ordered_float::OrderedFloat;
@@ -231,7 +231,7 @@ impl PickerDelegate for OutlineView {
     fn render_match(
         &self,
         ix: usize,
-        mouse_state: &MouseState,
+        mouse_state: MouseState,
         selected: bool,
         cx: &AppContext,
     ) -> ElementBox {

crates/picker/Cargo.toml 🔗

@@ -10,6 +10,7 @@ doctest = false
 [dependencies]
 editor = { path = "../editor" }
 gpui = { path = "../gpui" }
+menu = { path = "../menu" }
 settings = { path = "../settings" }
 util = { path = "../util" }
 theme = { path = "../theme" }

crates/picker/src/picker.rs 🔗

@@ -1,20 +1,18 @@
 use editor::Editor;
 use gpui::{
     elements::{
-        ChildView, Flex, Label, MouseEventHandler, MouseState, ParentElement, ScrollTarget,
-        UniformList, UniformListState,
+        ChildView, Flex, Label, MouseEventHandler, ParentElement, ScrollTarget, UniformList,
+        UniformListState,
     },
     geometry::vector::{vec2f, Vector2F},
     keymap,
     platform::CursorStyle,
-    AppContext, Axis, Element, ElementBox, Entity, MutableAppContext, RenderContext, Task, View,
-    ViewContext, ViewHandle, WeakViewHandle,
+    AppContext, Axis, Element, ElementBox, Entity, MouseState, MutableAppContext, RenderContext,
+    Task, View, ViewContext, ViewHandle, WeakViewHandle,
 };
+use menu::{Cancel, Confirm, SelectFirst, SelectIndex, SelectLast, SelectNext, SelectPrev};
 use settings::Settings;
 use std::cmp;
-use workspace::menu::{
-    Cancel, Confirm, SelectFirst, SelectIndex, SelectLast, SelectNext, SelectPrev,
-};
 
 pub struct Picker<D: PickerDelegate> {
     delegate: WeakViewHandle<D>,
@@ -34,7 +32,7 @@ pub trait PickerDelegate: View {
     fn render_match(
         &self,
         ix: usize,
-        state: &MouseState,
+        state: MouseState,
         selected: bool,
         cx: &AppContext,
     ) -> ElementBox;
@@ -54,6 +52,7 @@ impl<D: PickerDelegate> View for Picker<D> {
 
     fn render(&mut self, cx: &mut RenderContext<Self>) -> gpui::ElementBox {
         let settings = cx.global::<Settings>();
+        let container_style = settings.theme.picker.container;
         let delegate = self.delegate.clone();
         let match_count = if let Some(delegate) = delegate.upgrade(cx.app) {
             delegate.read(cx).match_count()
@@ -80,8 +79,9 @@ impl<D: PickerDelegate> View for Picker<D> {
                     UniformList::new(
                         self.list_state.clone(),
                         match_count,
-                        move |mut range, items, cx| {
-                            let delegate = delegate.upgrade(cx).unwrap();
+                        cx,
+                        move |this, mut range, items, cx| {
+                            let delegate = this.delegate.upgrade(cx).unwrap();
                             let selected_ix = delegate.read(cx).selected_index();
                             range.end = cmp::min(range.end, delegate.read(cx).match_count());
                             items.extend(range.map(move |ix| {
@@ -90,7 +90,7 @@ impl<D: PickerDelegate> View for Picker<D> {
                                         .read(cx)
                                         .render_match(ix, state, ix == selected_ix, cx)
                                 })
-                                .on_mouse_down(move |cx| cx.dispatch_action(SelectIndex(ix)))
+                                .on_mouse_down(move |_, cx| cx.dispatch_action(SelectIndex(ix)))
                                 .with_cursor_style(CursorStyle::PointingHand)
                                 .boxed()
                             }));
@@ -103,7 +103,7 @@ impl<D: PickerDelegate> View for Picker<D> {
                 .boxed(),
             )
             .contained()
-            .with_style(settings.theme.picker.container)
+            .with_style(container_style)
             .constrained()
             .with_max_width(self.max_size.x())
             .with_max_height(self.max_size.y())

crates/project/src/fs.rs 🔗

@@ -15,6 +15,7 @@ use text::Rope;
 pub trait Fs: Send + Sync {
     async fn create_dir(&self, path: &Path) -> Result<()>;
     async fn create_file(&self, path: &Path, options: CreateOptions) -> Result<()>;
+    async fn copy(&self, source: &Path, target: &Path, options: CopyOptions) -> Result<()>;
     async fn rename(&self, source: &Path, target: &Path, options: RenameOptions) -> Result<()>;
     async fn remove_dir(&self, path: &Path, options: RemoveOptions) -> Result<()>;
     async fn remove_file(&self, path: &Path, options: RemoveOptions) -> Result<()>;
@@ -44,6 +45,12 @@ pub struct CreateOptions {
     pub ignore_if_exists: bool,
 }
 
+#[derive(Copy, Clone, Default)]
+pub struct CopyOptions {
+    pub overwrite: bool,
+    pub ignore_if_exists: bool,
+}
+
 #[derive(Copy, Clone, Default)]
 pub struct RenameOptions {
     pub overwrite: bool,
@@ -84,6 +91,35 @@ impl Fs for RealFs {
         Ok(())
     }
 
+    async fn copy(&self, source: &Path, target: &Path, options: CopyOptions) -> Result<()> {
+        if !options.overwrite && smol::fs::metadata(target).await.is_ok() {
+            if options.ignore_if_exists {
+                return Ok(());
+            } else {
+                return Err(anyhow!("{target:?} already exists"));
+            }
+        }
+
+        let metadata = smol::fs::metadata(source).await?;
+        let _ = smol::fs::remove_dir_all(target).await;
+        if metadata.is_dir() {
+            self.create_dir(target).await?;
+            let mut children = smol::fs::read_dir(source).await?;
+            while let Some(child) = children.next().await {
+                if let Ok(child) = child {
+                    let child_source_path = child.path();
+                    let child_target_path = target.join(child.file_name());
+                    self.copy(&child_source_path, &child_target_path, options)
+                        .await?;
+                }
+            }
+        } else {
+            smol::fs::copy(source, target).await?;
+        }
+
+        Ok(())
+    }
+
     async fn rename(&self, source: &Path, target: &Path, options: RenameOptions) -> Result<()> {
         if !options.overwrite && smol::fs::metadata(target).await.is_ok() {
             if options.ignore_if_exists {
@@ -511,6 +547,40 @@ impl Fs for FakeFs {
         Ok(())
     }
 
+    async fn copy(&self, source: &Path, target: &Path, options: CopyOptions) -> Result<()> {
+        let source = normalize_path(source);
+        let target = normalize_path(target);
+
+        let mut state = self.state.lock().await;
+        state.validate_path(&source)?;
+        state.validate_path(&target)?;
+
+        if !options.overwrite && state.entries.contains_key(&target) {
+            if options.ignore_if_exists {
+                return Ok(());
+            } else {
+                return Err(anyhow!("{target:?} already exists"));
+            }
+        }
+
+        let mut new_entries = Vec::new();
+        for (path, entry) in &state.entries {
+            if let Ok(relative_path) = path.strip_prefix(&source) {
+                new_entries.push((relative_path.to_path_buf(), entry.clone()));
+            }
+        }
+
+        let mut events = Vec::new();
+        for (relative_path, entry) in new_entries {
+            let new_path = normalize_path(&target.join(relative_path));
+            events.push(new_path.clone());
+            state.entries.insert(new_path, entry);
+        }
+
+        state.emit_event(&events).await;
+        Ok(())
+    }
+
     async fn remove_dir(&self, dir_path: &Path, options: RemoveOptions) -> Result<()> {
         let dir_path = normalize_path(dir_path);
         let mut state = self.state.lock().await;

crates/project/src/project.rs 🔗

@@ -282,6 +282,7 @@ impl Project {
         client.add_model_message_handler(Self::handle_update_worktree);
         client.add_model_request_handler(Self::handle_create_project_entry);
         client.add_model_request_handler(Self::handle_rename_project_entry);
+        client.add_model_request_handler(Self::handle_copy_project_entry);
         client.add_model_request_handler(Self::handle_delete_project_entry);
         client.add_model_request_handler(Self::handle_apply_additional_edits_for_completion);
         client.add_model_request_handler(Self::handle_apply_code_action);
@@ -779,6 +780,49 @@ impl Project {
         }
     }
 
+    pub fn copy_entry(
+        &mut self,
+        entry_id: ProjectEntryId,
+        new_path: impl Into<Arc<Path>>,
+        cx: &mut ModelContext<Self>,
+    ) -> Option<Task<Result<Entry>>> {
+        let worktree = self.worktree_for_entry(entry_id, cx)?;
+        let new_path = new_path.into();
+        if self.is_local() {
+            worktree.update(cx, |worktree, cx| {
+                worktree
+                    .as_local_mut()
+                    .unwrap()
+                    .copy_entry(entry_id, new_path, cx)
+            })
+        } else {
+            let client = self.client.clone();
+            let project_id = self.remote_id().unwrap();
+
+            Some(cx.spawn_weak(|_, mut cx| async move {
+                let response = client
+                    .request(proto::CopyProjectEntry {
+                        project_id,
+                        entry_id: entry_id.to_proto(),
+                        new_path: new_path.as_os_str().as_bytes().to_vec(),
+                    })
+                    .await?;
+                let entry = response
+                    .entry
+                    .ok_or_else(|| anyhow!("missing entry in response"))?;
+                worktree
+                    .update(&mut cx, |worktree, cx| {
+                        worktree.as_remote().unwrap().insert_entry(
+                            entry,
+                            response.worktree_scan_id as usize,
+                            cx,
+                        )
+                    })
+                    .await
+            }))
+        }
+    }
+
     pub fn rename_entry(
         &mut self,
         entry_id: ProjectEntryId,
@@ -4037,6 +4081,34 @@ impl Project {
         })
     }
 
+    async fn handle_copy_project_entry(
+        this: ModelHandle<Self>,
+        envelope: TypedEnvelope<proto::CopyProjectEntry>,
+        _: Arc<Client>,
+        mut cx: AsyncAppContext,
+    ) -> Result<proto::ProjectEntryResponse> {
+        let entry_id = ProjectEntryId::from_proto(envelope.payload.entry_id);
+        let worktree = this.read_with(&cx, |this, cx| {
+            this.worktree_for_entry(entry_id, cx)
+                .ok_or_else(|| anyhow!("worktree not found"))
+        })?;
+        let worktree_scan_id = worktree.read_with(&cx, |worktree, _| worktree.scan_id());
+        let entry = worktree
+            .update(&mut cx, |worktree, cx| {
+                let new_path = PathBuf::from(OsString::from_vec(envelope.payload.new_path));
+                worktree
+                    .as_local_mut()
+                    .unwrap()
+                    .copy_entry(entry_id, new_path, cx)
+                    .ok_or_else(|| anyhow!("invalid entry"))
+            })?
+            .await?;
+        Ok(proto::ProjectEntryResponse {
+            entry: Some((&entry).into()),
+            worktree_scan_id: worktree_scan_id as u64,
+        })
+    }
+
     async fn handle_delete_project_entry(
         this: ModelHandle<Self>,
         envelope: TypedEnvelope<proto::DeleteProjectEntry>,

crates/project/src/worktree.rs 🔗

@@ -774,6 +774,46 @@ impl LocalWorktree {
         }))
     }
 
+    pub fn copy_entry(
+        &self,
+        entry_id: ProjectEntryId,
+        new_path: impl Into<Arc<Path>>,
+        cx: &mut ModelContext<Worktree>,
+    ) -> Option<Task<Result<Entry>>> {
+        let old_path = self.entry_for_id(entry_id)?.path.clone();
+        let new_path = new_path.into();
+        let abs_old_path = self.absolutize(&old_path);
+        let abs_new_path = self.absolutize(&new_path);
+        let copy = cx.background().spawn({
+            let fs = self.fs.clone();
+            let abs_new_path = abs_new_path.clone();
+            async move {
+                fs.copy(&abs_old_path, &abs_new_path, Default::default())
+                    .await
+            }
+        });
+
+        Some(cx.spawn(|this, mut cx| async move {
+            copy.await?;
+            let entry = this
+                .update(&mut cx, |this, cx| {
+                    this.as_local_mut().unwrap().refresh_entry(
+                        new_path.clone(),
+                        abs_new_path,
+                        None,
+                        cx,
+                    )
+                })
+                .await?;
+            this.update(&mut cx, |this, cx| {
+                this.poll_snapshot(cx);
+                this.as_local().unwrap().broadcast_snapshot()
+            })
+            .await;
+            Ok(entry)
+        }))
+    }
+
     fn write_entry_internal(
         &self,
         path: impl Into<Arc<Path>>,
@@ -1162,8 +1202,23 @@ impl Snapshot {
     }
 
     fn delete_entry(&mut self, entry_id: ProjectEntryId) -> bool {
-        if let Some(entry) = self.entries_by_id.remove(&entry_id, &()) {
-            self.entries_by_path.remove(&PathKey(entry.path), &());
+        if let Some(removed_entry) = self.entries_by_id.remove(&entry_id, &()) {
+            self.entries_by_path = {
+                let mut cursor = self.entries_by_path.cursor();
+                let mut new_entries_by_path =
+                    cursor.slice(&TraversalTarget::Path(&removed_entry.path), Bias::Left, &());
+                while let Some(entry) = cursor.item() {
+                    if entry.path.starts_with(&removed_entry.path) {
+                        self.entries_by_id.remove(&entry.id, &());
+                        cursor.next(&());
+                    } else {
+                        break;
+                    }
+                }
+                new_entries_by_path.push_tree(cursor.suffix(&()), &());
+                new_entries_by_path
+            };
+
             true
         } else {
             false

crates/project_panel/Cargo.toml 🔗

@@ -8,8 +8,10 @@ path = "src/project_panel.rs"
 doctest = false
 
 [dependencies]
+context_menu = { path = "../context_menu" }
 editor = { path = "../editor" }
 gpui = { path = "../gpui" }
+menu = { path = "../menu" }
 project = { path = "../project" }
 settings = { path = "../settings" }
 theme = { path = "../theme" }

crates/project_panel/src/project_panel.rs 🔗

@@ -1,3 +1,4 @@
+use context_menu::{ContextMenu, ContextMenuItem};
 use editor::{Cancel, Editor};
 use futures::stream::StreamExt;
 use gpui::{
@@ -5,13 +6,15 @@ use gpui::{
     anyhow::{anyhow, Result},
     elements::{
         ChildView, ConstrainedBox, Empty, Flex, Label, MouseEventHandler, ParentElement,
-        ScrollTarget, Svg, UniformList, UniformListState,
+        ScrollTarget, Stack, Svg, UniformList, UniformListState,
     },
+    geometry::vector::Vector2F,
     impl_internal_actions, keymap,
     platform::CursorStyle,
-    AppContext, Element, ElementBox, Entity, ModelHandle, MutableAppContext, PromptLevel, Task,
-    View, ViewContext, ViewHandle, WeakViewHandle,
+    AppContext, ClipboardItem, Element, ElementBox, Entity, ModelHandle, MutableAppContext,
+    PromptLevel, RenderContext, Task, View, ViewContext, ViewHandle,
 };
+use menu::{Confirm, SelectNext, SelectPrev};
 use project::{Entry, EntryKind, Project, ProjectEntryId, ProjectPath, Worktree, WorktreeId};
 use settings::Settings;
 use std::{
@@ -19,12 +22,10 @@ use std::{
     collections::{hash_map, HashMap},
     ffi::OsStr,
     ops::Range,
+    path::{Path, PathBuf},
 };
 use unicase::UniCase;
-use workspace::{
-    menu::{Confirm, SelectNext, SelectPrev},
-    Workspace,
-};
+use workspace::Workspace;
 
 const NEW_ENTRY_ID: ProjectEntryId = ProjectEntryId::MAX;
 
@@ -36,7 +37,8 @@ pub struct ProjectPanel {
     selection: Option<Selection>,
     edit_state: Option<EditState>,
     filename_editor: ViewHandle<Editor>,
-    handle: WeakViewHandle<Self>,
+    clipboard_entry: Option<ClipboardEntry>,
+    context_menu: ViewHandle<ContextMenu>,
 }
 
 #[derive(Copy, Clone)]
@@ -54,6 +56,18 @@ struct EditState {
     processing_filename: Option<String>,
 }
 
+#[derive(Copy, Clone)]
+pub enum ClipboardEntry {
+    Copied {
+        worktree_id: WorktreeId,
+        entry_id: ProjectEntryId,
+    },
+    Cut {
+        worktree_id: WorktreeId,
+        entry_id: ProjectEntryId,
+    },
+}
+
 #[derive(Debug, PartialEq, Eq)]
 struct EntryDetails {
     filename: String,
@@ -64,6 +78,7 @@ struct EntryDetails {
     is_selected: bool,
     is_editing: bool,
     is_processing: bool,
+    is_cut: bool,
 }
 
 #[derive(Clone)]
@@ -75,6 +90,12 @@ pub struct Open {
     pub change_focus: bool,
 }
 
+#[derive(Clone)]
+pub struct DeployContextMenu {
+    pub position: Vector2F,
+    pub entry_id: Option<ProjectEntryId>,
+}
+
 actions!(
     project_panel,
     [
@@ -82,13 +103,18 @@ actions!(
         CollapseSelectedEntry,
         AddDirectory,
         AddFile,
+        Copy,
+        CopyPath,
+        Cut,
+        Paste,
         Delete,
         Rename
     ]
 );
-impl_internal_actions!(project_panel, [Open, ToggleExpanded]);
+impl_internal_actions!(project_panel, [Open, ToggleExpanded, DeployContextMenu]);
 
 pub fn init(cx: &mut MutableAppContext) {
+    cx.add_action(ProjectPanel::deploy_context_menu);
     cx.add_action(ProjectPanel::expand_selected_entry);
     cx.add_action(ProjectPanel::collapse_selected_entry);
     cx.add_action(ProjectPanel::toggle_expanded);
@@ -101,6 +127,14 @@ pub fn init(cx: &mut MutableAppContext) {
     cx.add_async_action(ProjectPanel::delete);
     cx.add_async_action(ProjectPanel::confirm);
     cx.add_action(ProjectPanel::cancel);
+    cx.add_action(ProjectPanel::copy);
+    cx.add_action(ProjectPanel::copy_path);
+    cx.add_action(ProjectPanel::cut);
+    cx.add_action(
+        |this: &mut ProjectPanel, action: &Paste, cx: &mut ViewContext<ProjectPanel>| {
+            this.paste(action, cx);
+        },
+    );
 }
 
 pub enum Event {
@@ -156,7 +190,8 @@ impl ProjectPanel {
                 selection: None,
                 edit_state: None,
                 filename_editor,
-                handle: cx.weak_handle(),
+                clipboard_entry: None,
+                context_menu: cx.add_view(|cx| ContextMenu::new(cx)),
             };
             this.update_visible_entries(None, cx);
             this
@@ -195,6 +230,63 @@ impl ProjectPanel {
         project_panel
     }
 
+    fn deploy_context_menu(&mut self, action: &DeployContextMenu, cx: &mut ViewContext<Self>) {
+        let mut menu_entries = Vec::new();
+
+        if let Some(entry_id) = action.entry_id {
+            if let Some(worktree_id) = self.project.read(cx).worktree_id_for_entry(entry_id, cx) {
+                self.selection = Some(Selection {
+                    worktree_id,
+                    entry_id,
+                });
+
+                if let Some((worktree, entry)) = self.selected_entry(cx) {
+                    let is_root = Some(entry) == worktree.root_entry();
+                    if !self.project.read(cx).is_remote() {
+                        menu_entries.push(ContextMenuItem::item(
+                            "Add Folder to Project",
+                            workspace::AddFolderToProject,
+                        ));
+                        if is_root {
+                            menu_entries.push(ContextMenuItem::item(
+                                "Remove Folder from Project",
+                                workspace::RemoveFolderFromProject(worktree_id),
+                            ));
+                        }
+                    }
+                    menu_entries.push(ContextMenuItem::item("New File", AddFile));
+                    menu_entries.push(ContextMenuItem::item("New Folder", AddDirectory));
+                    menu_entries.push(ContextMenuItem::Separator);
+                    menu_entries.push(ContextMenuItem::item("Copy", Copy));
+                    menu_entries.push(ContextMenuItem::item("Copy Path", CopyPath));
+                    menu_entries.push(ContextMenuItem::item("Cut", Cut));
+                    if let Some(clipboard_entry) = self.clipboard_entry {
+                        if clipboard_entry.worktree_id() == worktree.id() {
+                            menu_entries.push(ContextMenuItem::item("Paste", Paste));
+                        }
+                    }
+                    menu_entries.push(ContextMenuItem::Separator);
+                    menu_entries.push(ContextMenuItem::item("Rename", Rename));
+                    if !is_root {
+                        menu_entries.push(ContextMenuItem::item("Delete", Delete));
+                    }
+                }
+            }
+        } else {
+            self.selection.take();
+            menu_entries.push(ContextMenuItem::item(
+                "Add Folder to Project",
+                workspace::AddFolderToProject,
+            ));
+        }
+
+        self.context_menu.update(cx, |menu, cx| {
+            menu.show(action.position, menu_entries, cx);
+        });
+
+        cx.notify();
+    }
+
     fn expand_selected_entry(&mut self, _: &ExpandSelectedEntry, cx: &mut ViewContext<Self>) {
         if let Some((worktree, entry)) = self.selected_entry(cx) {
             let expanded_dir_ids =
@@ -541,6 +633,92 @@ impl ProjectPanel {
         }
     }
 
+    fn cut(&mut self, _: &Cut, cx: &mut ViewContext<Self>) {
+        if let Some((worktree, entry)) = self.selected_entry(cx) {
+            self.clipboard_entry = Some(ClipboardEntry::Cut {
+                worktree_id: worktree.id(),
+                entry_id: entry.id,
+            });
+            cx.notify();
+        }
+    }
+
+    fn copy(&mut self, _: &Copy, cx: &mut ViewContext<Self>) {
+        if let Some((worktree, entry)) = self.selected_entry(cx) {
+            self.clipboard_entry = Some(ClipboardEntry::Copied {
+                worktree_id: worktree.id(),
+                entry_id: entry.id,
+            });
+            cx.notify();
+        }
+    }
+
+    fn paste(&mut self, _: &Paste, cx: &mut ViewContext<Self>) -> Option<()> {
+        if let Some((worktree, entry)) = self.selected_entry(cx) {
+            let clipboard_entry = self.clipboard_entry?;
+            if clipboard_entry.worktree_id() != worktree.id() {
+                return None;
+            }
+
+            let clipboard_entry_file_name = self
+                .project
+                .read(cx)
+                .path_for_entry(clipboard_entry.entry_id(), cx)?
+                .path
+                .file_name()?
+                .to_os_string();
+
+            let mut new_path = entry.path.to_path_buf();
+            if entry.is_file() {
+                new_path.pop();
+            }
+
+            new_path.push(&clipboard_entry_file_name);
+            let extension = new_path.extension().map(|e| e.to_os_string());
+            let file_name_without_extension = Path::new(&clipboard_entry_file_name).file_stem()?;
+            let mut ix = 0;
+            while worktree.entry_for_path(&new_path).is_some() {
+                new_path.pop();
+
+                let mut new_file_name = file_name_without_extension.to_os_string();
+                new_file_name.push(" copy");
+                if ix > 0 {
+                    new_file_name.push(format!(" {}", ix));
+                }
+                new_path.push(new_file_name);
+                if let Some(extension) = extension.as_ref() {
+                    new_path.set_extension(&extension);
+                }
+                ix += 1;
+            }
+
+            self.clipboard_entry.take();
+            if clipboard_entry.is_cut() {
+                self.project
+                    .update(cx, |project, cx| {
+                        project.rename_entry(clipboard_entry.entry_id(), new_path, cx)
+                    })
+                    .map(|task| task.detach_and_log_err(cx));
+            } else {
+                self.project
+                    .update(cx, |project, cx| {
+                        project.copy_entry(clipboard_entry.entry_id(), new_path, cx)
+                    })
+                    .map(|task| task.detach_and_log_err(cx));
+            }
+        }
+        None
+    }
+
+    fn copy_path(&mut self, _: &CopyPath, cx: &mut ViewContext<Self>) {
+        if let Some((worktree, entry)) = self.selected_entry(cx) {
+            let mut path = PathBuf::new();
+            path.push(worktree.root_name());
+            path.push(&entry.path);
+            cx.write_to_clipboard(ClipboardItem::new(path.to_string_lossy().to_string()));
+        }
+    }
+
     fn index_for_selection(&self, selection: Selection) -> Option<(usize, usize, usize)> {
         let mut worktree_index = 0;
         let mut entry_index = 0;
@@ -706,8 +884,8 @@ impl ProjectPanel {
     fn for_each_visible_entry(
         &self,
         range: Range<usize>,
-        cx: &mut ViewContext<ProjectPanel>,
-        mut callback: impl FnMut(ProjectEntryId, EntryDetails, &mut ViewContext<ProjectPanel>),
+        cx: &mut RenderContext<ProjectPanel>,
+        mut callback: impl FnMut(ProjectEntryId, EntryDetails, &mut RenderContext<ProjectPanel>),
     ) {
         let mut ix = 0;
         for (worktree_id, visible_worktree_entries) in &self.visible_entries {
@@ -747,6 +925,9 @@ impl ProjectPanel {
                         }),
                         is_editing: false,
                         is_processing: false,
+                        is_cut: self
+                            .clipboard_entry
+                            .map_or(false, |e| e.is_cut() && e.entry_id() == entry.id),
                     };
                     if let Some(edit_state) = &self.edit_state {
                         let is_edited_entry = if edit_state.is_new_entry {
@@ -780,7 +961,7 @@ impl ProjectPanel {
         details: EntryDetails,
         editor: &ViewHandle<Editor>,
         theme: &theme::ProjectPanel,
-        cx: &mut ViewContext<Self>,
+        cx: &mut RenderContext<Self>,
     ) -> ElementBox {
         let kind = details.kind;
         let show_editor = details.is_editing && !details.is_processing;
@@ -791,6 +972,10 @@ impl ProjectPanel {
                 style.text.color.fade_out(theme.ignored_entry_fade);
                 style.icon_color.fade_out(theme.ignored_entry_fade);
             }
+            if details.is_cut {
+                style.text.color.fade_out(theme.cut_entry_fade);
+                style.icon_color.fade_out(theme.cut_entry_fade);
+            }
             let row_container_style = if show_editor {
                 theme.filename_editor.container
             } else {
@@ -841,7 +1026,7 @@ impl ProjectPanel {
                 .with_padding_left(padding)
                 .boxed()
         })
-        .on_click(move |click_count, cx| {
+        .on_click(move |_, click_count, cx| {
             if kind == EntryKind::Dir {
                 cx.dispatch_action(ToggleExpanded(entry_id))
             } else {
@@ -851,6 +1036,12 @@ impl ProjectPanel {
                 })
             }
         })
+        .on_right_mouse_down(move |position, cx| {
+            cx.dispatch_action(DeployContextMenu {
+                entry_id: Some(entry_id),
+                position,
+            })
+        })
         .with_cursor_style(CursorStyle::PointingHand)
         .boxed()
     }
@@ -862,37 +1053,50 @@ impl View for ProjectPanel {
     }
 
     fn render(&mut self, cx: &mut gpui::RenderContext<'_, Self>) -> gpui::ElementBox {
+        enum Tag {}
         let theme = &cx.global::<Settings>().theme.project_panel;
         let mut container_style = theme.container;
         let padding = std::mem::take(&mut container_style.padding);
-        let handle = self.handle.clone();
-        UniformList::new(
-            self.list.clone(),
-            self.visible_entries
-                .iter()
-                .map(|(_, worktree_entries)| worktree_entries.len())
-                .sum(),
-            move |range, items, cx| {
-                let theme = cx.global::<Settings>().theme.clone();
-                let this = handle.upgrade(cx).unwrap();
-                this.update(cx.app, |this, cx| {
-                    this.for_each_visible_entry(range.clone(), cx, |id, details, cx| {
-                        items.push(Self::render_entry(
-                            id,
-                            details,
-                            &this.filename_editor,
-                            &theme.project_panel,
-                            cx,
-                        ));
-                    });
+        Stack::new()
+            .with_child(
+                MouseEventHandler::new::<Tag, _, _>(0, cx, |_, cx| {
+                    UniformList::new(
+                        self.list.clone(),
+                        self.visible_entries
+                            .iter()
+                            .map(|(_, worktree_entries)| worktree_entries.len())
+                            .sum(),
+                        cx,
+                        move |this, range, items, cx| {
+                            let theme = cx.global::<Settings>().theme.clone();
+                            this.for_each_visible_entry(range.clone(), cx, |id, details, cx| {
+                                items.push(Self::render_entry(
+                                    id,
+                                    details,
+                                    &this.filename_editor,
+                                    &theme.project_panel,
+                                    cx,
+                                ));
+                            });
+                        },
+                    )
+                    .with_padding_top(padding.top)
+                    .with_padding_bottom(padding.bottom)
+                    .contained()
+                    .with_style(container_style)
+                    .expanded()
+                    .boxed()
                 })
-            },
-        )
-        .with_padding_top(padding.top)
-        .with_padding_bottom(padding.bottom)
-        .contained()
-        .with_style(container_style)
-        .boxed()
+                .on_right_mouse_down(move |position, cx| {
+                    cx.dispatch_action(DeployContextMenu {
+                        entry_id: None,
+                        position,
+                    })
+                })
+                .boxed(),
+            )
+            .with_child(ChildView::new(&self.context_menu).boxed())
+            .boxed()
     }
 
     fn keymap_context(&self, _: &AppContext) -> keymap::Context {
@@ -912,6 +1116,27 @@ impl workspace::sidebar::SidebarItem for ProjectPanel {
     }
 }
 
+impl ClipboardEntry {
+    fn is_cut(&self) -> bool {
+        matches!(self, Self::Cut { .. })
+    }
+
+    fn entry_id(&self) -> ProjectEntryId {
+        match self {
+            ClipboardEntry::Copied { entry_id, .. } | ClipboardEntry::Cut { entry_id, .. } => {
+                *entry_id
+            }
+        }
+    }
+
+    fn worktree_id(&self) -> WorktreeId {
+        match self {
+            ClipboardEntry::Copied { worktree_id, .. }
+            | ClipboardEntry::Cut { worktree_id, .. } => *worktree_id,
+        }
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -1343,7 +1568,7 @@ mod tests {
         let mut result = Vec::new();
         let mut project_entries = HashSet::new();
         let mut has_editor = false;
-        panel.update(cx, |panel, cx| {
+        cx.render(panel, |panel, cx| {
             panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
                 if details.is_editing {
                     assert!(!has_editor, "duplicate editor entry");

crates/project_symbols/src/project_symbols.rs 🔗

@@ -3,8 +3,8 @@ use editor::{
 };
 use fuzzy::{StringMatch, StringMatchCandidate};
 use gpui::{
-    actions, elements::*, AppContext, Entity, ModelHandle, MutableAppContext, RenderContext, Task,
-    View, ViewContext, ViewHandle,
+    actions, elements::*, AppContext, Entity, ModelHandle, MouseState, MutableAppContext,
+    RenderContext, Task, View, ViewContext, ViewHandle,
 };
 use ordered_float::OrderedFloat;
 use picker::{Picker, PickerDelegate};
@@ -221,7 +221,7 @@ impl PickerDelegate for ProjectSymbolsView {
     fn render_match(
         &self,
         ix: usize,
-        mouse_state: &MouseState,
+        mouse_state: MouseState,
         selected: bool,
         cx: &AppContext,
     ) -> ElementBox {

crates/rpc/proto/zed.proto 🔗

@@ -41,66 +41,67 @@ message Envelope {
 
         CreateProjectEntry create_project_entry = 33;
         RenameProjectEntry rename_project_entry = 34;
-        DeleteProjectEntry delete_project_entry = 35;
-        ProjectEntryResponse project_entry_response = 36;
-
-        UpdateDiagnosticSummary update_diagnostic_summary = 37;
-        StartLanguageServer start_language_server = 38;
-        UpdateLanguageServer update_language_server = 39;
-
-        OpenBufferById open_buffer_by_id = 40;
-        OpenBufferByPath open_buffer_by_path = 41;
-        OpenBufferResponse open_buffer_response = 42;
-        UpdateBuffer update_buffer = 43;
-        UpdateBufferFile update_buffer_file = 44;
-        SaveBuffer save_buffer = 45;
-        BufferSaved buffer_saved = 46;
-        BufferReloaded buffer_reloaded = 47;
-        ReloadBuffers reload_buffers = 48;
-        ReloadBuffersResponse reload_buffers_response = 49;
-        FormatBuffers format_buffers = 50;
-        FormatBuffersResponse format_buffers_response = 51;
-        GetCompletions get_completions = 52;
-        GetCompletionsResponse get_completions_response = 53;
-        ApplyCompletionAdditionalEdits apply_completion_additional_edits = 54;
-        ApplyCompletionAdditionalEditsResponse apply_completion_additional_edits_response = 55;
-        GetCodeActions get_code_actions = 56;
-        GetCodeActionsResponse get_code_actions_response = 57;
-        ApplyCodeAction apply_code_action = 58;
-        ApplyCodeActionResponse apply_code_action_response = 59;
-        PrepareRename prepare_rename = 60;
-        PrepareRenameResponse prepare_rename_response = 61;
-        PerformRename perform_rename = 62;
-        PerformRenameResponse perform_rename_response = 63;
-        SearchProject search_project = 64;
-        SearchProjectResponse search_project_response = 65;
-
-        GetChannels get_channels = 66;
-        GetChannelsResponse get_channels_response = 67;
-        JoinChannel join_channel = 68;
-        JoinChannelResponse join_channel_response = 69;
-        LeaveChannel leave_channel = 70;
-        SendChannelMessage send_channel_message = 71;
-        SendChannelMessageResponse send_channel_message_response = 72;
-        ChannelMessageSent channel_message_sent = 73;
-        GetChannelMessages get_channel_messages = 74;
-        GetChannelMessagesResponse get_channel_messages_response = 75;
-
-        UpdateContacts update_contacts = 76;
-        UpdateInviteInfo update_invite_info = 77;
-        ShowContacts show_contacts = 78;
-
-        GetUsers get_users = 79;
-        FuzzySearchUsers fuzzy_search_users = 80;
-        UsersResponse users_response = 81;
-        RequestContact request_contact = 82;
-        RespondToContactRequest respond_to_contact_request = 83;
-        RemoveContact remove_contact = 84;
-
-        Follow follow = 85;
-        FollowResponse follow_response = 86;
-        UpdateFollowers update_followers = 87;
-        Unfollow unfollow = 88;
+        CopyProjectEntry copy_project_entry = 35;
+        DeleteProjectEntry delete_project_entry = 36;
+        ProjectEntryResponse project_entry_response = 37;
+
+        UpdateDiagnosticSummary update_diagnostic_summary = 38;
+        StartLanguageServer start_language_server = 39;
+        UpdateLanguageServer update_language_server = 40;
+
+        OpenBufferById open_buffer_by_id = 41;
+        OpenBufferByPath open_buffer_by_path = 42;
+        OpenBufferResponse open_buffer_response = 43;
+        UpdateBuffer update_buffer = 44;
+        UpdateBufferFile update_buffer_file = 45;
+        SaveBuffer save_buffer = 46;
+        BufferSaved buffer_saved = 47;
+        BufferReloaded buffer_reloaded = 48;
+        ReloadBuffers reload_buffers = 49;
+        ReloadBuffersResponse reload_buffers_response = 50;
+        FormatBuffers format_buffers = 51;
+        FormatBuffersResponse format_buffers_response = 52;
+        GetCompletions get_completions = 53;
+        GetCompletionsResponse get_completions_response = 54;
+        ApplyCompletionAdditionalEdits apply_completion_additional_edits = 55;
+        ApplyCompletionAdditionalEditsResponse apply_completion_additional_edits_response = 56;
+        GetCodeActions get_code_actions = 57;
+        GetCodeActionsResponse get_code_actions_response = 58;
+        ApplyCodeAction apply_code_action = 59;
+        ApplyCodeActionResponse apply_code_action_response = 60;
+        PrepareRename prepare_rename = 61;
+        PrepareRenameResponse prepare_rename_response = 62;
+        PerformRename perform_rename = 63;
+        PerformRenameResponse perform_rename_response = 64;
+        SearchProject search_project = 65;
+        SearchProjectResponse search_project_response = 66;
+
+        GetChannels get_channels = 67;
+        GetChannelsResponse get_channels_response = 68;
+        JoinChannel join_channel = 69;
+        JoinChannelResponse join_channel_response = 70;
+        LeaveChannel leave_channel = 71;
+        SendChannelMessage send_channel_message = 72;
+        SendChannelMessageResponse send_channel_message_response = 73;
+        ChannelMessageSent channel_message_sent = 74;
+        GetChannelMessages get_channel_messages = 75;
+        GetChannelMessagesResponse get_channel_messages_response = 76;
+
+        UpdateContacts update_contacts = 77;
+        UpdateInviteInfo update_invite_info = 78;
+        ShowContacts show_contacts = 79;
+
+        GetUsers get_users = 80;
+        FuzzySearchUsers fuzzy_search_users = 81;
+        UsersResponse users_response = 82;
+        RequestContact request_contact = 83;
+        RespondToContactRequest respond_to_contact_request = 84;
+        RemoveContact remove_contact = 85;
+
+        Follow follow = 86;
+        FollowResponse follow_response = 87;
+        UpdateFollowers update_followers = 88;
+        Unfollow unfollow = 89;
     }
 }
 
@@ -210,6 +211,12 @@ message RenameProjectEntry {
     bytes new_path = 3;
 }
 
+message CopyProjectEntry {
+    uint64 project_id = 1;
+    uint64 entry_id = 2;
+    bytes new_path = 3;
+}
+
 message DeleteProjectEntry {
     uint64 project_id = 1;
     uint64 entry_id = 2;

crates/rpc/src/proto.rs 🔗

@@ -84,6 +84,7 @@ messages!(
     (BufferSaved, Foreground),
     (RemoveContact, Foreground),
     (ChannelMessageSent, Foreground),
+    (CopyProjectEntry, Foreground),
     (CreateProjectEntry, Foreground),
     (DeleteProjectEntry, Foreground),
     (Error, Foreground),
@@ -167,6 +168,7 @@ request_messages!(
         ApplyCompletionAdditionalEdits,
         ApplyCompletionAdditionalEditsResponse
     ),
+    (CopyProjectEntry, ProjectEntryResponse),
     (CreateProjectEntry, ProjectEntryResponse),
     (DeleteProjectEntry, ProjectEntryResponse),
     (Follow, FollowResponse),
@@ -211,8 +213,8 @@ entity_messages!(
     ApplyCompletionAdditionalEdits,
     BufferReloaded,
     BufferSaved,
+    CopyProjectEntry,
     CreateProjectEntry,
-    RenameProjectEntry,
     DeleteProjectEntry,
     Follow,
     FormatBuffers,
@@ -233,6 +235,7 @@ entity_messages!(
     ProjectUnshared,
     ReloadBuffers,
     RemoveProjectCollaborator,
+    RenameProjectEntry,
     RequestJoinProject,
     SaveBuffer,
     SearchProject,

crates/rpc/src/rpc.rs 🔗

@@ -6,4 +6,4 @@ pub use conn::Connection;
 pub use peer::*;
 mod macros;
 
-pub const PROTOCOL_VERSION: u32 = 20;
+pub const PROTOCOL_VERSION: u32 = 21;

crates/search/Cargo.toml 🔗

@@ -12,6 +12,7 @@ collections = { path = "../collections" }
 editor = { path = "../editor" }
 gpui = { path = "../gpui" }
 language = { path = "../language" }
+menu = { path = "../menu" }
 project = { path = "../project" }
 settings = { path = "../settings" }
 theme = { path = "../theme" }

crates/search/src/buffer_search.rs 🔗

@@ -290,7 +290,7 @@ impl BufferSearchBar {
                 .with_style(style.container)
                 .boxed()
         })
-        .on_click(move |_, cx| cx.dispatch_action(ToggleSearchOption(search_option)))
+        .on_click(move |_, _, cx| cx.dispatch_action(ToggleSearchOption(search_option)))
         .with_cursor_style(CursorStyle::PointingHand)
         .boxed()
     }
@@ -314,7 +314,7 @@ impl BufferSearchBar {
                 .with_style(style.container)
                 .boxed()
         })
-        .on_click(move |_, cx| match direction {
+        .on_click(move |_, _, cx| match direction {
             Direction::Prev => cx.dispatch_action(SelectPrevMatch),
             Direction::Next => cx.dispatch_action(SelectNextMatch),
         })

crates/search/src/project_search.rs 🔗

@@ -9,6 +9,7 @@ use gpui::{
     ModelHandle, MutableAppContext, RenderContext, Subscription, Task, View, ViewContext,
     ViewHandle, WeakModelHandle, WeakViewHandle,
 };
+use menu::Confirm;
 use project::{search::SearchQuery, Project};
 use settings::Settings;
 use smallvec::SmallVec;
@@ -19,8 +20,7 @@ use std::{
 };
 use util::ResultExt as _;
 use workspace::{
-    menu::Confirm, Item, ItemHandle, ItemNavHistory, Pane, ToolbarItemLocation, ToolbarItemView,
-    Workspace,
+    Item, ItemHandle, ItemNavHistory, Pane, ToolbarItemLocation, ToolbarItemView, Workspace,
 };
 
 actions!(project_search, [Deploy, SearchInNew, ToggleFocus]);
@@ -672,7 +672,7 @@ impl ProjectSearchBar {
                 .with_style(style.container)
                 .boxed()
         })
-        .on_click(move |_, cx| match direction {
+        .on_click(move |_, _, cx| match direction {
             Direction::Prev => cx.dispatch_action(SelectPrevMatch),
             Direction::Next => cx.dispatch_action(SelectNextMatch),
         })
@@ -699,7 +699,7 @@ impl ProjectSearchBar {
                 .with_style(style.container)
                 .boxed()
         })
-        .on_click(move |_, cx| cx.dispatch_action(ToggleSearchOption(option)))
+        .on_click(move |_, _, cx| cx.dispatch_action(ToggleSearchOption(option)))
         .with_cursor_style(CursorStyle::PointingHand)
         .boxed()
     }

crates/theme/src/theme.rs 🔗

@@ -2,9 +2,9 @@ mod theme_registry;
 
 use gpui::{
     color::Color,
-    elements::{ContainerStyle, ImageStyle, LabelStyle, MouseState},
+    elements::{ContainerStyle, ImageStyle, LabelStyle},
     fonts::{HighlightStyle, TextStyle},
-    Border,
+    Border, MouseState,
 };
 use serde::{de::DeserializeOwned, Deserialize};
 use serde_json::Value;
@@ -19,6 +19,7 @@ pub struct Theme {
     #[serde(default)]
     pub name: String,
     pub workspace: Workspace,
+    pub context_menu: ContextMenu,
     pub chat_panel: ChatPanel,
     pub contacts_panel: ContactsPanel,
     pub contact_finder: ContactFinder,
@@ -223,6 +224,7 @@ pub struct ProjectPanel {
     #[serde(flatten)]
     pub container: ContainerStyle,
     pub entry: Interactive<ProjectPanelEntry>,
+    pub cut_entry_fade: f32,
     pub ignored_entry_fade: f32,
     pub filename_editor: FieldEditor,
     pub indent_width: f32,
@@ -239,6 +241,22 @@ pub struct ProjectPanelEntry {
     pub icon_spacing: f32,
 }
 
+#[derive(Clone, Debug, Deserialize, Default)]
+pub struct ContextMenu {
+    #[serde(flatten)]
+    pub container: ContainerStyle,
+    pub item: Interactive<ContextMenuItem>,
+    pub separator: ContainerStyle,
+}
+
+#[derive(Clone, Debug, Deserialize, Default)]
+pub struct ContextMenuItem {
+    #[serde(flatten)]
+    pub container: ContainerStyle,
+    pub label: TextStyle,
+    pub keystroke: ContainedText,
+}
+
 #[derive(Debug, Deserialize, Default)]
 pub struct CommandPalette {
     pub key: Interactive<ContainedLabel>,
@@ -488,7 +506,7 @@ pub struct Interactive<T> {
 }
 
 impl<T> Interactive<T> {
-    pub fn style_for(&self, state: &MouseState, active: bool) -> &T {
+    pub fn style_for(&self, state: MouseState, active: bool) -> &T {
         if active {
             if state.hovered {
                 self.active_hover

crates/theme_selector/src/theme_selector.rs 🔗

@@ -1,6 +1,6 @@
 use fuzzy::{match_strings, StringMatch, StringMatchCandidate};
 use gpui::{
-    actions, elements::*, AppContext, Element, ElementBox, Entity, MutableAppContext,
+    actions, elements::*, AppContext, Element, ElementBox, Entity, MouseState, MutableAppContext,
     RenderContext, View, ViewContext, ViewHandle,
 };
 use picker::{Picker, PickerDelegate};
@@ -213,7 +213,7 @@ impl PickerDelegate for ThemeSelector {
     fn render_match(
         &self,
         ix: usize,
-        mouse_state: &MouseState,
+        mouse_state: MouseState,
         selected: bool,
         cx: &AppContext,
     ) -> ElementBox {

crates/workspace/src/lsp_status.rs 🔗

@@ -168,7 +168,8 @@ impl View for LspStatus {
                     self.failed.join(", "),
                     if self.failed.len() > 1 { "s" } else { "" }
                 );
-                handler = Some(|_, cx: &mut EventContext| cx.dispatch_action(DismissErrorMessage));
+                handler =
+                    Some(|_, _, cx: &mut EventContext| cx.dispatch_action(DismissErrorMessage));
             } else {
                 return Empty::new().boxed();
             }

crates/workspace/src/pane.rs 🔗

@@ -702,6 +702,7 @@ impl Pane {
         let theme = cx.global::<Settings>().theme.clone();
 
         enum Tabs {}
+        enum Tab {}
         let pane = cx.handle();
         let tabs = MouseEventHandler::new::<Tabs, _, _>(0, cx, |mouse_state, cx| {
             let autoscroll = if mem::take(&mut self.autoscroll) {
@@ -730,7 +731,7 @@ impl Pane {
                         style.container.border.left = false;
                     }
 
-                    EventHandler::new(
+                    MouseEventHandler::new::<Tab, _, _>(ix, cx, |_, cx| {
                         Container::new(
                             Flex::row()
                                 .with_child(
@@ -801,7 +802,7 @@ impl Pane {
                                             .with_cursor_style(CursorStyle::PointingHand)
                                             .on_click({
                                                 let pane = pane.clone();
-                                                move |_, cx| {
+                                                move |_, _, cx| {
                                                     cx.dispatch_action(CloseItem {
                                                         item_id,
                                                         pane: pane.clone(),
@@ -820,11 +821,10 @@ impl Pane {
                                 .boxed(),
                         )
                         .with_style(style.container)
-                        .boxed(),
-                    )
-                    .on_mouse_down(move |cx| {
+                        .boxed()
+                    })
+                    .on_mouse_down(move |_, cx| {
                         cx.dispatch_action(ActivateItem(ix));
-                        true
                     })
                     .boxed()
                 })

crates/workspace/src/sidebar.rs 🔗

@@ -165,6 +165,7 @@ impl Sidebar {
             ..Default::default()
         })
         .with_cursor_style(CursorStyle::ResizeLeftRight)
+        .on_mouse_down(|_, _| {}) // This prevents the mouse down event from being propagated elsewhere
         .on_drag(move |delta, cx| {
             let prev_width = *actual_width.borrow();
             *custom_width.borrow_mut() = 0f32
@@ -293,7 +294,7 @@ impl View for SidebarButtons {
                                 .boxed()
                         })
                         .with_cursor_style(CursorStyle::PointingHand)
-                        .on_click(move |_, cx| {
+                        .on_click(move |_, _, cx| {
                             cx.dispatch_action(ToggleSidebarItem {
                                 side,
                                 item_index: ix,

crates/workspace/src/workspace.rs 🔗

@@ -1,5 +1,4 @@
 pub mod lsp_status;
-pub mod menu;
 pub mod pane;
 pub mod pane_group;
 pub mod sidebar;
@@ -30,7 +29,7 @@ use log::error;
 pub use pane::*;
 pub use pane_group::*;
 use postage::prelude::Stream;
-use project::{fs, Fs, Project, ProjectEntryId, ProjectPath, Worktree};
+use project::{fs, Fs, Project, ProjectEntryId, ProjectPath, Worktree, WorktreeId};
 use settings::Settings;
 use sidebar::{Side, Sidebar, SidebarButtons, ToggleSidebarItem, ToggleSidebarItemFocus};
 use smallvec::SmallVec;
@@ -73,6 +72,9 @@ type FollowableItemBuilders = HashMap<
     ),
 >;
 
+#[derive(Clone)]
+pub struct RemoveFolderFromProject(pub WorktreeId);
+
 actions!(
     workspace,
     [
@@ -105,7 +107,15 @@ pub struct JoinProject {
     pub project_index: usize,
 }
 
-impl_internal_actions!(workspace, [OpenPaths, ToggleFollow, JoinProject]);
+impl_internal_actions!(
+    workspace,
+    [
+        OpenPaths,
+        ToggleFollow,
+        JoinProject,
+        RemoveFolderFromProject
+    ]
+);
 
 pub fn init(app_state: Arc<AppState>, cx: &mut MutableAppContext) {
     pane::init(cx);
@@ -149,6 +159,7 @@ pub fn init(app_state: Arc<AppState>, cx: &mut MutableAppContext) {
     cx.add_async_action(Workspace::close);
     cx.add_async_action(Workspace::save_all);
     cx.add_action(Workspace::add_folder_to_project);
+    cx.add_action(Workspace::remove_folder_from_project);
     cx.add_action(
         |workspace: &mut Workspace, _: &Unfollow, cx: &mut ViewContext<Workspace>| {
             let pane = workspace.active_pane().clone();
@@ -1034,6 +1045,15 @@ impl Workspace {
         .detach();
     }
 
+    fn remove_folder_from_project(
+        &mut self,
+        RemoveFolderFromProject(worktree_id): &RemoveFolderFromProject,
+        cx: &mut ViewContext<Self>,
+    ) {
+        self.project
+            .update(cx, |project, cx| project.remove_worktree(*worktree_id, cx));
+    }
+
     fn project_path_for_path(
         &self,
         abs_path: &Path,
@@ -1777,7 +1797,7 @@ impl Workspace {
                         .with_style(style.container)
                         .boxed()
                 })
-                .on_click(|_, cx| cx.dispatch_action(Authenticate))
+                .on_click(|_, _, cx| cx.dispatch_action(Authenticate))
                 .with_cursor_style(CursorStyle::PointingHand)
                 .aligned()
                 .boxed(),
@@ -1828,7 +1848,7 @@ impl Workspace {
         if let Some(peer_id) = peer_id {
             MouseEventHandler::new::<ToggleFollow, _, _>(replica_id.into(), cx, move |_, _| content)
                 .with_cursor_style(CursorStyle::PointingHand)
-                .on_click(move |_, cx| cx.dispatch_action(ToggleFollow(peer_id)))
+                .on_click(move |_, _, cx| cx.dispatch_action(ToggleFollow(peer_id)))
                 .boxed()
         } else {
             content

crates/zed/Cargo.toml 🔗

@@ -22,6 +22,7 @@ chat_panel = { path = "../chat_panel" }
 cli = { path = "../cli" }
 collections = { path = "../collections" }
 command_palette = { path = "../command_palette" }
+context_menu = { path = "../context_menu" }
 client = { path = "../client" }
 clock = { path = "../clock" }
 contacts_panel = { path = "../contacts_panel" }

crates/zed/src/main.rs 🔗

@@ -134,6 +134,7 @@ fn main() {
         let mut languages = languages::build_language_registry(login_shell_env_loaded);
         let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http.clone(), cx));
 
+        context_menu::init(cx);
         auto_update::init(http, client::ZED_SERVER_URL.clone(), cx);
         project::Project::init(&client);
         client::Channel::init(&client);

styles/src/styleTree/app.ts 🔗

@@ -9,6 +9,7 @@ import projectPanel from "./projectPanel";
 import search from "./search";
 import picker from "./picker";
 import workspace from "./workspace";
+import contextMenu from "./contextMenu";
 import projectDiagnostics from "./projectDiagnostics";
 import contactNotification from "./contactNotification";
 
@@ -20,6 +21,7 @@ export default function app(theme: Theme): Object {
   return {
     picker: picker(theme),
     workspace: workspace(theme),
+    contextMenu: contextMenu(theme),
     editor: editor(theme),
     projectDiagnostics: projectDiagnostics(theme),
     commandPalette: commandPalette(theme),

styles/src/styleTree/contextMenu.ts 🔗

@@ -0,0 +1,36 @@
+import Theme from "../themes/common/theme";
+import { backgroundColor, borderColor, shadow, text } from "./components";
+
+export default function contextMenu(theme: Theme) {
+  return {
+    background: backgroundColor(theme, 300, "base"),
+    cornerRadius: 6,
+    padding: 6,
+    shadow: shadow(theme),
+    item: {
+      padding: { left: 4, right: 4, top: 2, bottom: 2 },
+      cornerRadius: 6,
+      label: text(theme, "sans", "secondary", { size: "sm" }),
+      keystroke: {
+        margin: { left: 60 },
+        ...text(theme, "sans", "muted", { size: "sm", weight: "bold" })
+      },
+      hover: {
+        background: backgroundColor(theme, 300, "hovered"),
+        text: text(theme, "sans", "primary", { size: "sm" }),
+      },
+      active: {
+        background: backgroundColor(theme, 300, "active"),
+        text: text(theme, "sans", "primary", { size: "sm" }),
+      },
+      activeHover: {
+        background: backgroundColor(theme, 300, "hovered"),
+        text: text(theme, "sans", "active", { size: "sm" }),
+      }
+    },
+    separator: {
+      background: borderColor(theme, "primary"),
+      margin: { top: 2, bottom: 2 }
+    },
+  }
+}

styles/src/styleTree/projectPanel.ts 🔗

@@ -26,6 +26,7 @@ export default function projectPanel(theme: Theme) {
         text: text(theme, "mono", "active", { size: "sm" }),
       }
     },
+    cutEntryFade: 0.4,
     ignoredEntryFade: 0.6,
     filenameEditor: {
       background: backgroundColor(theme, 500, "active"),