diff --git a/Cargo.lock b/Cargo.lock index 9e52d78e29..6493a15c2f 100644 --- a/Cargo.lock +++ b/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", diff --git a/assets/keymaps/default.json b/assets/keymaps/default.json index 831c076b5c..12a6e8e5a0 100644 --- a/assets/keymaps/default.json +++ b/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" } diff --git a/crates/auto_update/src/auto_update.rs b/crates/auto_update/src/auto_update.rs index 499b3ed99d..234319bdd6 100644 --- a/crates/auto_update/src/auto_update.rs +++ b/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(), diff --git a/crates/chat_panel/Cargo.toml b/crates/chat_panel/Cargo.toml index 95426517d7..e54245502f 100644 --- a/crates/chat_panel/Cargo.toml +++ b/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" } diff --git a/crates/chat_panel/src/chat_panel.rs b/crates/chat_panel/src/chat_panel.rs index 460e01c527..a8db280bf8 100644 --- a/crates/chat_panel/src/chat_panel.rs +++ b/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 { diff --git a/crates/collab/src/integration_tests.rs b/crates/collab/src/integration_tests.rs index 9500027e7a..56822d21da 100644 --- a/crates/collab/src/integration_tests.rs +++ b/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::>(), + [ + "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::>(), + [ + "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::>(), + [ + "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::>(), + [ + "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::>(), - ["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::>(), - ["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::>(), - ["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::>(), - ["a.txt", "b.txt"] + ["a.txt", "b.txt", "f.txt"] ); }); } diff --git a/crates/collab/src/main.rs b/crates/collab/src/main.rs index f8a9fedb66..b3be40040c 100644 --- a/crates/collab/src/main.rs +++ b/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() diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 858684aee9..5e0c6f7789 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -171,6 +171,7 @@ impl Server { .add_request_handler(Server::forward_project_request::) .add_request_handler(Server::forward_project_request::) .add_request_handler(Server::forward_project_request::) + .add_request_handler(Server::forward_project_request::) .add_request_handler(Server::forward_project_request::) .add_request_handler(Server::update_buffer) .add_message_handler(Server::update_buffer_file) diff --git a/crates/command_palette/src/command_palette.rs b/crates/command_palette/src/command_palette.rs index 9f0f396d85..0708826ea7 100644 --- a/crates/command_palette/src/command_palette.rs +++ b/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 { diff --git a/crates/contacts_panel/Cargo.toml b/crates/contacts_panel/Cargo.toml index 800bad497d..ab05a56ce7 100644 --- a/crates/contacts_panel/Cargo.toml +++ b/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" } diff --git a/crates/contacts_panel/src/contact_finder.rs b/crates/contacts_panel/src/contact_finder.rs index 18e17a93d9..244cfcad4a 100644 --- a/crates/contacts_panel/src/contact_finder.rs +++ b/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 { diff --git a/crates/contacts_panel/src/contacts_panel.rs b/crates/contacts_panel/src/contacts_panel.rs index 44aa0626c5..13485e96f2 100644 --- a/crates/contacts_panel/src/contacts_panel.rs +++ b/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::().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, ) -> 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, ) -> 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, ) -> 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(), )); diff --git a/crates/contacts_panel/src/notifications.rs b/crates/contacts_panel/src/notifications.rs index 555d8962d3..c02fd73b8f 100644 --- a/crates/contacts_panel/src/notifications.rs +++ b/crates/contacts_panel/src/notifications.rs @@ -61,7 +61,7 @@ pub fn render_user_notification( }) .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( .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( .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() }, )) diff --git a/crates/context_menu/Cargo.toml b/crates/context_menu/Cargo.toml new file mode 100644 index 0000000000..817893f43e --- /dev/null +++ b/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" diff --git a/crates/context_menu/src/context_menu.rs b/crates/context_menu/src/context_menu.rs new file mode 100644 index 0000000000..273bb588af --- /dev/null +++ b/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, + }, + 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 { + match self { + ContextMenuItem::Item { action, .. } => Some(action.id()), + ContextMenuItem::Separator => None, + } + } +} + +pub struct ContextMenu { + position: Vector2F, + items: Vec, + selected_index: Option, + visible: bool, + previously_focused_view_id: Option, + _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) -> 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.reset(cx); + } +} + +impl ContextMenu { + pub fn new(cx: &mut ViewContext) -> 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) { + 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) { + 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.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.items.clear(); + self.visible = false; + self.selected_index.take(); + cx.notify(); + } + + fn select_first(&mut self, _: &SelectFirst, cx: &mut ViewContext) { + self.selected_index = self.items.iter().position(|item| !item.is_separator()); + cx.notify(); + } + + fn select_last(&mut self, _: &SelectLast, cx: &mut ViewContext) { + 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) { + 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) { + 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, + cx: &mut ViewContext, + ) { + 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) -> impl Element { + let style = cx.global::().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) -> impl Element { + enum Menu {} + enum MenuItem {} + let style = cx.global::().theme.context_menu.clone(); + MouseEventHandler::new::(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::(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)) + } +} diff --git a/crates/diagnostics/src/items.rs b/crates/diagnostics/src/items.rs index 426f25629d..224e5e94a7 100644 --- a/crates/diagnostics/src/items.rs +++ b/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(), ); } diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index f4f3483641..d82a5baece 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -592,11 +592,11 @@ impl ContextMenu { &self, cursor_position: DisplayPoint, style: EditorStyle, - cx: &AppContext, + cx: &mut RenderContext, ) -> (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) -> 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::( - 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 - }; + 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::( + 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(), - ); - } - }) + 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, ) -> (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, + cx: &mut RenderContext, ) -> Option { 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, ) -> Option<(DisplayPoint, ElementBox)> { self.context_menu .as_ref() diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index d5a59c2ecc..d5a4f22eb5 100644 --- a/crates/editor/src/element.rs +++ b/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::(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, diff --git a/crates/file_finder/Cargo.toml b/crates/file_finder/Cargo.toml index ca3eb6b429..554cf433a2 100644 --- a/crates/file_finder/Cargo.toml +++ b/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" } diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index f58c733cc7..cdefa6b593 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/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() { diff --git a/crates/go_to_line/Cargo.toml b/crates/go_to_line/Cargo.toml index 76744274c7..93ae96f93e 100644 --- a/crates/go_to_line/Cargo.toml +++ b/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"] } diff --git a/crates/go_to_line/src/go_to_line.rs b/crates/go_to_line/src/go_to_line.rs index bae5ffc46c..f2df235a7b 100644 --- a/crates/go_to_line/src/go_to_line.rs +++ b/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]); diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index ef71a5971d..19de98b3ce 100644 --- a/crates/gpui/src/app.rs +++ b/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 { - fn current_view_id(&self) -> usize; - - fn element_state( - &mut self, - element_id: usize, - ) -> ElementStateHandle { - let id = ElementStateId { - view_id: self.current_view_id(), - element_id, - tag: TypeId::of::(), - }; - 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>, @@ -467,6 +448,27 @@ impl TestAppContext { result } + pub fn render(&mut self, handle: &ViewHandle, f: F) -> T + where + F: FnOnce(&mut V, &mut RenderContext) -> T, + V: View, + { + handle.update(&mut *self.cx.borrow_mut(), |view, cx| { + let mut render_cx = RenderContext { + app: cx, + window_id: handle.window_id(), + view_id: handle.id(), + view_type: PhantomData, + titlebar_height: 0., + hovered_region_ids: Default::default(), + clicked_region_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 bool>; type FocusObservationCallback = Box bool>; type GlobalObservationCallback = Box; type ReleaseObservationCallback = Box; +type ActionObservationCallback = Box; type DeserializeActionCallback = fn(json: &str) -> anyhow::Result>; pub struct MutableAppContext { @@ -793,6 +796,7 @@ pub struct MutableAppContext { global_observations: Arc>>>>, release_observations: Arc>>>, + action_dispatch_observations: Arc>>, presenters_and_platform_windows: HashMap>, Box)>, foreground: Rc, @@ -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 { + pub fn render_view(&mut self, params: RenderParams) -> Result { + 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(&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> { + 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( - &mut self, - window_id: usize, - view_id: usize, - titlebar_height: f32, - refreshing: bool, - ) -> RenderContext { - RenderContext { - app: self, - titlebar_height, - refreshing, - window_id, - view_id, - view_type: PhantomData, - } - } - pub fn add_view(&mut self, window_id: usize, build_view: F) -> ViewHandle 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) { + 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) { 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>>>; 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::, - 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(&mut self, mut callback: F) -> Subscription + where + F: 'static + FnMut(&mut T, TypeId, &mut ViewContext), + { + 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, + pub clicked_region_id: Option, + pub right_clicked_region_id: Option, + pub refreshing: bool, +} + pub struct RenderContext<'a, T: View> { + pub(crate) window_id: usize, + pub(crate) view_id: usize, + pub(crate) view_type: PhantomData, + pub(crate) hovered_region_ids: HashSet, + pub(crate) clicked_region_id: Option, + pub(crate) right_clicked_region_id: Option, pub app: &'a mut MutableAppContext, pub titlebar_height: f32, pub refreshing: bool, - window_id: usize, - view_id: usize, - view_type: PhantomData, } -impl<'a, T: View> RenderContext<'a, T> { - pub fn handle(&self) -> WeakViewHandle { +#[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 { WeakViewHandle::new(self.window_id, self.view_id) } pub fn view_id(&self) -> usize { self.view_id } + + pub fn mouse_state(&self, region_id: usize) -> MouseState { + let region_id = MouseRegionId { + view_id: self.view_id, + discriminant: (TypeId::of::(), region_id), + }; + MouseState { + hovered: self.hovered_region_ids.contains(®ion_id), + clicked: self.clicked_region_id == Some(region_id), + right_clicked: self.right_clicked_region_id == Some(region_id), + } + } + + pub fn element_state( + &mut self, + element_id: usize, + ) -> ElementStateHandle { + let id = ElementStateId { + view_id: self.view_id(), + element_id, + tag: TypeId::of::(), + }; + 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 for &AppContext { @@ -3508,12 +3623,6 @@ impl ReadView for RenderContext<'_, V> { } } -impl ElementStateContext for RenderContext<'_, V> { - fn current_view_id(&self) -> usize { - self.view_id - } -} - impl AsRef for ViewContext<'_, M> { fn as_ref(&self) -> &AppContext { &self.app.cx @@ -3573,6 +3682,16 @@ impl UpgradeViewHandle for ViewContext<'_, V> { } } +impl UpgradeViewHandle for RenderContext<'_, V> { + fn upgrade_view_handle(&self, handle: &WeakViewHandle) -> Option> { + self.cx.upgrade_view_handle(handle) + } + + fn upgrade_any_view_handle(&self, handle: &AnyWeakViewHandle) -> Option { + self.cx.upgrade_any_view_handle(handle) + } +} + impl UpdateModel for ViewContext<'_, V> { fn update_model( &mut self, @@ -3602,12 +3721,6 @@ impl UpdateView for ViewContext<'_, V> { } } -impl ElementStateContext for ViewContext<'_, V> { - fn current_view_id(&self) -> usize { - self.view_id - } -} - pub trait Handle { type Weak: 'static; fn id(&self) -> usize; @@ -4636,6 +4749,10 @@ pub enum Subscription { observations: Option>>>>, }, + ActionObservation { + id: usize, + observations: Option>>>, + }, } 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)] diff --git a/crates/gpui/src/elements.rs b/crates/gpui/src/elements.rs index 55c7bf22fe..231339d9e0 100644 --- a/crates/gpui/src/elements.rs +++ b/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::{ diff --git a/crates/gpui/src/elements/constrained_box.rs b/crates/gpui/src/elements/constrained_box.rs index f12ed6900a..5ab01df1e1 100644 --- a/crates/gpui/src/elements/constrained_box.rs +++ b/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 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)}) } } diff --git a/crates/gpui/src/elements/container.rs b/crates/gpui/src/elements/container.rs index 62f19636b7..004052b9ba 100644 --- a/crates/gpui/src/elements/container.rs +++ b/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 = diff --git a/crates/gpui/src/elements/flex.rs b/crates/gpui/src/elements/flex.rs index 3f42f98407..8d1fb37a08 100644 --- a/crates/gpui/src/elements/flex.rs +++ b/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( + pub fn scrollable( mut self, element_id: usize, scroll_to: Option, - cx: &mut C, + cx: &mut RenderContext, ) -> Self where Tag: 'static, - C: ElementStateContext, + V: View, { let scroll_state = cx.element_state::(element_id); scroll_state.update(cx, |scroll_state, _| scroll_state.scroll_to = scroll_to); diff --git a/crates/gpui/src/elements/keystroke_label.rs b/crates/gpui/src/elements/keystroke_label.rs new file mode 100644 index 0000000000..2cd55e5ff0 --- /dev/null +++ b/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, + container_style: ContainerStyle, + text_style: TextStyle, +} + +impl KeystrokeLabel { + pub fn new( + action: Box, + 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) + }) + } +} diff --git a/crates/gpui/src/elements/list.rs b/crates/gpui/src/elements/list.rs index 77d37bc3bf..6479f2ee28 100644 --- a/crates/gpui/src/elements/list.rs +++ b/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, - render_item: Box ElementBox>, + render_item: Box Option>, rendered_range: Range, items: SumTree, logical_scroll_top: Option, @@ -131,13 +131,27 @@ impl Element for List { let mut cursor = old_items.cursor::(); 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( + pub fn new( element_count: usize, orientation: Orientation, overdraw: f32, - render_item: F, + cx: &mut ViewContext, + mut render_item: F, ) -> Self where - F: 'static + FnMut(usize, &mut LayoutContext) -> ElementBox, + V: View, + F: 'static + FnMut(&mut V, usize, &mut RenderContext) -> 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 { + 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, diff --git a/crates/gpui/src/elements/mouse_event_handler.rs b/crates/gpui/src/elements/mouse_event_handler.rs index 1ee7c6cbb5..2ad6eaf028 100644 --- a/crates/gpui/src/elements/mouse_event_handler.rs +++ b/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, child: ElementBox, + tag: TypeId, + id: usize, cursor_style: Option, - mouse_down_handler: Option>, - click_handler: Option>, - drag_handler: Option>, + mouse_down: Option>, + click: Option>, + right_mouse_down: Option>, + right_click: Option>, + mouse_down_out: Option>, + right_mouse_down_out: Option>, + drag: Option>, padding: Padding, } -#[derive(Default)] -pub struct MouseState { - pub hovered: bool, - pub clicked: bool, - prev_drag_position: Option, -} - impl MouseEventHandler { - pub fn new(id: usize, cx: &mut C, render_child: F) -> Self + pub fn new(id: usize, cx: &mut RenderContext, render_child: F) -> Self where Tag: 'static, - C: ElementStateContext, - F: FnOnce(&MouseState, &mut C) -> ElementBox, + V: View, + F: FnOnce(MouseState, &mut RenderContext) -> ElementBox, { - let state_handle = cx.element_state::(id); - let child = state_handle.update(cx, |state, cx| render_child(state, cx)); Self { - state: state_handle, - child, + id, + tag: TypeId::of::(), + child: render_child(cx.mouse_state::(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 FnMut(usize, &mut EventContext) + 'static) -> Self { - self.click_handler = Some(Box::new(handler)); + 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_drag(mut self, handler: impl FnMut(Vector2F, &mut EventContext) + 'static) -> Self { - self.drag_handler = Some(Box::new(handler)); + 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_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 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( diff --git a/crates/gpui/src/elements/overlay.rs b/crates/gpui/src/elements/overlay.rs index 0cac2ed863..d841bcbc04 100644 --- a/crates/gpui/src/elements/overlay.rs +++ b/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, } 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), + }) } } diff --git a/crates/gpui/src/elements/uniform_list.rs b/crates/gpui/src/elements/uniform_list.rs index 3f384b5ea5..de217a017c 100644 --- a/crates/gpui/src/elements/uniform_list.rs +++ b/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, } -pub struct UniformList -where - F: Fn(Range, &mut Vec, &mut LayoutContext), -{ +pub struct UniformList { state: UniformListState, item_count: usize, - append_items: F, + append_items: Box, &mut Vec, &mut LayoutContext)>, padding_top: f32, padding_bottom: f32, get_width_from_item: Option, } -impl UniformList -where - F: Fn(Range, &mut Vec, &mut LayoutContext), -{ - pub fn new(state: UniformListState, item_count: usize, append_items: F) -> Self { +impl UniformList { + pub fn new( + state: UniformListState, + item_count: usize, + cx: &mut RenderContext, + append_items: F, + ) -> Self + where + V: View, + F: 'static + Fn(&mut V, Range, &mut Vec, &mut RenderContext), + { + 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 Element for UniformList -where - F: Fn(Range, &mut Vec, &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 { diff --git a/crates/gpui/src/gpui.rs b/crates/gpui/src/gpui.rs index e58bbec1c6..c7d619fd7d 100644 --- a/crates/gpui/src/gpui.rs +++ b/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; diff --git a/crates/gpui/src/keymap.rs b/crates/gpui/src/keymap.rs index bd156ed661..87b0287dc4 100644 --- a/crates/gpui/src/keymap.rs +++ b/crates/gpui/src/keymap.rs @@ -30,9 +30,9 @@ pub struct Keymap { } pub struct Binding { - keystrokes: Vec, + keystrokes: SmallVec<[Keystroke; 2]>, action: Box, - context: Option, + context_predicate: Option, } #[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> { + 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 { diff --git a/crates/gpui/src/platform/event.rs b/crates/gpui/src/platform/event.rs index b32ab952c7..61cfa99bfe 100644 --- a/crates/gpui/src/platform/event.rs +++ b/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), diff --git a/crates/gpui/src/platform/mac/event.rs b/crates/gpui/src/platform/mac/event.rs index 9d07177b16..4d3aa6cf9a 100644 --- a/crates/gpui/src/platform/mac/event.rs +++ b/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() { diff --git a/crates/gpui/src/presenter.rs b/crates/gpui/src/presenter.rs index fbdd6963e3..87efeb2e5f 100644 --- a/crates/gpui/src/presenter.rs +++ b/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, parents: HashMap, - cursor_styles: Vec<(RectF, CursorStyle)>, + cursor_regions: Vec, + mouse_regions: Vec<(MouseRegion, usize)>, font_cache: Arc, text_layout_cache: TextLayoutCache, asset_cache: Arc, last_mouse_moved_event: Option, + hovered_region_ids: HashSet, + clicked_region: Option, + right_clicked_region: Option, + prev_drag_position: Option, 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 { + 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 { - let mut path = Vec::new(); + pub(crate) fn compute_dispatch_path_from(&self, mut view_id: usize, path: &mut Vec) { 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(®ion_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(®ion_id) { + invalidated_views.push(region.view_id); + unhovered_regions.push(region.clone()); + self.hovered_region_ids.remove(®ion_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, + pub dispatcher_view_id: Option, pub action: Box, } pub struct LayoutContext<'a> { + window_id: usize, rendered_views: &'a mut HashMap, parents: &'a mut HashMap, view_stack: Vec, - pub refreshing: bool, pub font_cache: &'a Arc, pub font_system: Arc, 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, + clicked_region_id: Option, + right_clicked_region_id: Option, } impl<'a> LayoutContext<'a> { + pub(crate) fn keystrokes_for_action( + &self, + action: &dyn Action, + ) -> Option> { + 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(&mut self, handle: &ViewHandle, f: F) -> T + where + F: FnOnce(&mut V, &mut RenderContext) -> 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, + view_stack: Vec, 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(&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) { 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!({ diff --git a/crates/gpui/src/scene.rs b/crates/gpui/src/scene.rs index 7c358b85a0..1f503c8bf7 100644 --- a/crates/gpui/src/scene.rs +++ b/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, active_layer_stack: Vec, + depth: usize, } #[derive(Default)] @@ -33,7 +34,35 @@ pub struct Layer { image_glyphs: Vec, icons: Vec, paths: Vec, - cursor_styles: Vec<(RectF, CursorStyle)>, + cursor_regions: Vec, + mouse_regions: Vec, +} + +#[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>, + pub mouse_down: Option>, + pub click: Option>, + pub right_mouse_down: Option>, + pub right_click: Option>, + pub drag: Option>, + pub mouse_down_out: Option>, + pub right_mouse_down_out: Option>, +} + +#[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 { 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) { + 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) -> Self { + fn new(depth: usize, clip_bounds: Option) -> 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 { + 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. diff --git a/crates/gpui/src/views/select.rs b/crates/gpui/src/views/select.rs index d5d2105c3f..80c3ba2884 100644 --- a/crates/gpui/src/views/select.rs +++ b/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() })) }, diff --git a/crates/menu/Cargo.toml b/crates/menu/Cargo.toml new file mode 100644 index 0000000000..cdcacd4416 --- /dev/null +++ b/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" } diff --git a/crates/workspace/src/menu.rs b/crates/menu/src/menu.rs similarity index 100% rename from crates/workspace/src/menu.rs rename to crates/menu/src/menu.rs diff --git a/crates/outline/src/outline.rs b/crates/outline/src/outline.rs index 57c7441bfe..19b309116a 100644 --- a/crates/outline/src/outline.rs +++ b/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 { diff --git a/crates/picker/Cargo.toml b/crates/picker/Cargo.toml index 4528f00687..c74b6927ae 100644 --- a/crates/picker/Cargo.toml +++ b/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" } diff --git a/crates/picker/src/picker.rs b/crates/picker/src/picker.rs index 67db36208b..8f8e5d26b9 100644 --- a/crates/picker/src/picker.rs +++ b/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 { delegate: WeakViewHandle, @@ -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 View for Picker { fn render(&mut self, cx: &mut RenderContext) -> gpui::ElementBox { let settings = cx.global::(); + 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 View for Picker { 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 View for Picker { .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 View for Picker { .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()) diff --git a/crates/project/src/fs.rs b/crates/project/src/fs.rs index 7da2a38a83..2eec02d66d 100644 --- a/crates/project/src/fs.rs +++ b/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; diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 808561f110..2e549c5d3f 100644 --- a/crates/project/src/project.rs +++ b/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>, + cx: &mut ModelContext, + ) -> Option>> { + 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, + envelope: TypedEnvelope, + _: Arc, + mut cx: AsyncAppContext, + ) -> Result { + 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, envelope: TypedEnvelope, diff --git a/crates/project/src/worktree.rs b/crates/project/src/worktree.rs index 039ee0d838..cadfaa520d 100644 --- a/crates/project/src/worktree.rs +++ b/crates/project/src/worktree.rs @@ -774,6 +774,46 @@ impl LocalWorktree { })) } + pub fn copy_entry( + &self, + entry_id: ProjectEntryId, + new_path: impl Into>, + cx: &mut ModelContext, + ) -> Option>> { + 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>, @@ -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 diff --git a/crates/project_panel/Cargo.toml b/crates/project_panel/Cargo.toml index 257bac21d9..6d566699fa 100644 --- a/crates/project_panel/Cargo.toml +++ b/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" } diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 7056eb9ceb..cf8fd1d0c8 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/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, edit_state: Option, filename_editor: ViewHandle, - handle: WeakViewHandle, + clipboard_entry: Option, + context_menu: ViewHandle, } #[derive(Copy, Clone)] @@ -54,6 +56,18 @@ struct EditState { processing_filename: Option, } +#[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, +} + 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| { + 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) { + 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) { 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) { + 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) { + 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) -> 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) { + 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, - cx: &mut ViewContext, - mut callback: impl FnMut(ProjectEntryId, EntryDetails, &mut ViewContext), + cx: &mut RenderContext, + mut callback: impl FnMut(ProjectEntryId, EntryDetails, &mut RenderContext), ) { 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, theme: &theme::ProjectPanel, - cx: &mut ViewContext, + cx: &mut RenderContext, ) -> 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::().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::().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::(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::().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"); diff --git a/crates/project_symbols/src/project_symbols.rs b/crates/project_symbols/src/project_symbols.rs index 20da49b1df..ea99767e0a 100644 --- a/crates/project_symbols/src/project_symbols.rs +++ b/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 { diff --git a/crates/rpc/proto/zed.proto b/crates/rpc/proto/zed.proto index 91935f2be6..3b4d8cc4f9 100644 --- a/crates/rpc/proto/zed.proto +++ b/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; + CopyProjectEntry copy_project_entry = 35; + DeleteProjectEntry delete_project_entry = 36; + ProjectEntryResponse project_entry_response = 37; - UpdateDiagnosticSummary update_diagnostic_summary = 37; - StartLanguageServer start_language_server = 38; - UpdateLanguageServer update_language_server = 39; + UpdateDiagnosticSummary update_diagnostic_summary = 38; + StartLanguageServer start_language_server = 39; + UpdateLanguageServer update_language_server = 40; - 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; + 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 = 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; + 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 = 76; - UpdateInviteInfo update_invite_info = 77; - ShowContacts show_contacts = 78; + UpdateContacts update_contacts = 77; + UpdateInviteInfo update_invite_info = 78; + ShowContacts show_contacts = 79; - 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; + 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 = 85; - FollowResponse follow_response = 86; - UpdateFollowers update_followers = 87; - Unfollow unfollow = 88; + 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; diff --git a/crates/rpc/src/proto.rs b/crates/rpc/src/proto.rs index 67a12fcd87..7fe715064f 100644 --- a/crates/rpc/src/proto.rs +++ b/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, diff --git a/crates/rpc/src/rpc.rs b/crates/rpc/src/rpc.rs index 27b666d6d0..9512a43043 100644 --- a/crates/rpc/src/rpc.rs +++ b/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; diff --git a/crates/search/Cargo.toml b/crates/search/Cargo.toml index 56c4fff651..3e80b5979e 100644 --- a/crates/search/Cargo.toml +++ b/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" } diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index 5581cbd608..94b6261a0f 100644 --- a/crates/search/src/buffer_search.rs +++ b/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), }) diff --git a/crates/search/src/project_search.rs b/crates/search/src/project_search.rs index 4549aa4f90..9943ce5ded 100644 --- a/crates/search/src/project_search.rs +++ b/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() } diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index bc9e93025d..a8c28f41f3 100644 --- a/crates/theme/src/theme.rs +++ b/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, + 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, + 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, @@ -488,7 +506,7 @@ pub struct Interactive { } impl Interactive { - 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 diff --git a/crates/theme_selector/src/theme_selector.rs b/crates/theme_selector/src/theme_selector.rs index 9f445c633a..106e6ad429 100644 --- a/crates/theme_selector/src/theme_selector.rs +++ b/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 { diff --git a/crates/workspace/src/lsp_status.rs b/crates/workspace/src/lsp_status.rs index f58e0b973e..ab1ae4931f 100644 --- a/crates/workspace/src/lsp_status.rs +++ b/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(); } diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index f6c516a445..e8e6764273 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -702,6 +702,7 @@ impl Pane { let theme = cx.global::().theme.clone(); enum Tabs {} + enum Tab {} let pane = cx.handle(); let tabs = MouseEventHandler::new::(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::(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() }) diff --git a/crates/workspace/src/sidebar.rs b/crates/workspace/src/sidebar.rs index afdacc2a31..9aaf2b832a 100644 --- a/crates/workspace/src/sidebar.rs +++ b/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, diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index fc8d3ba16e..2c77c72f13 100644 --- a/crates/workspace/src/workspace.rs +++ b/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, cx: &mut MutableAppContext) { pane::init(cx); @@ -149,6 +159,7 @@ pub fn init(app_state: Arc, 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| { 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.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::(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 diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 525569b869..97a50e78d2 100644 --- a/crates/zed/Cargo.toml +++ b/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" } diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 40ef2a84ab..821b1e2ebc 100644 --- a/crates/zed/src/main.rs +++ b/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); diff --git a/styles/src/styleTree/app.ts b/styles/src/styleTree/app.ts index 230bd3e57f..41266ff5f7 100644 --- a/styles/src/styleTree/app.ts +++ b/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), diff --git a/styles/src/styleTree/contextMenu.ts b/styles/src/styleTree/contextMenu.ts new file mode 100644 index 0000000000..4115c29372 --- /dev/null +++ b/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 } + }, + } +} diff --git a/styles/src/styleTree/projectPanel.ts b/styles/src/styleTree/projectPanel.ts index 2f3e3eea72..f68f69711c 100644 --- a/styles/src/styleTree/projectPanel.ts +++ b/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"),