From f638d4ce1d81ce4a0722091a9b64e06a19160b7c Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Thu, 16 Nov 2023 14:32:37 +0200 Subject: [PATCH] Add basic context menu element --- Cargo.lock | 15 + Cargo.toml | 1 + crates/context_menu2/Cargo.toml | 19 + crates/context_menu2/src/context_menu.rs | 557 ++++++++++++++++++ crates/terminal_view2/Cargo.toml | 2 +- crates/terminal_view2/src/terminal_element.rs | 3 +- crates/terminal_view2/src/terminal_view.rs | 67 ++- crates/zed2/Cargo.toml | 2 +- crates/zed2/src/main.rs | 2 +- 9 files changed, 633 insertions(+), 35 deletions(-) create mode 100644 crates/context_menu2/Cargo.toml create mode 100644 crates/context_menu2/src/context_menu.rs diff --git a/Cargo.lock b/Cargo.lock index 4bbada23d0..87c370f59e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1989,6 +1989,19 @@ dependencies = [ "theme", ] +[[package]] +name = "context_menu2" +version = "0.1.0" +dependencies = [ + "anyhow", + "gpui2", + "menu2", + "settings2", + "smallvec", + "theme2", + "workspace2", +] + [[package]] name = "convert_case" version = "0.4.0" @@ -9181,6 +9194,7 @@ version = "0.1.0" dependencies = [ "anyhow", "client2", + "context_menu2", "db2", "dirs 4.0.0", "editor2", @@ -11518,6 +11532,7 @@ dependencies = [ "collab_ui2", "collections", "command_palette2", + "context_menu2", "copilot2", "ctor", "db2", diff --git a/Cargo.toml b/Cargo.toml index f8d0af77fa..65c7630077 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ members = [ "crates/command_palette2", "crates/component_test", "crates/context_menu", + "crates/context_menu2", "crates/copilot", "crates/copilot2", "crates/copilot_button", diff --git a/crates/context_menu2/Cargo.toml b/crates/context_menu2/Cargo.toml new file mode 100644 index 0000000000..5c8aae03a1 --- /dev/null +++ b/crates/context_menu2/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "context_menu2" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +path = "src/context_menu.rs" +doctest = false + +[dependencies] +gpui = { package = "gpui2", path = "../gpui2" } +menu = { package = "menu2", path = "../menu2" } +settings = { package = "settings2", path = "../settings2" } +theme = { package = "theme2", path = "../theme2" } +workspace = { package = "workspace2", path = "../workspace2" } + +anyhow.workspace = true +smallvec.workspace = true diff --git a/crates/context_menu2/src/context_menu.rs b/crates/context_menu2/src/context_menu.rs new file mode 100644 index 0000000000..885c5c8521 --- /dev/null +++ b/crates/context_menu2/src/context_menu.rs @@ -0,0 +1,557 @@ +#![allow(unused_variables, unused)] +//todo!(remove) + +use gpui::{ + div, Action, AnchorCorner, AnyElement, AppContext, BorrowWindow, Div, EntityId, FocusHandle, + FocusableView, Pixels, Point, Render, ViewContext, +}; +use menu::*; + +use std::{any::TypeId, borrow::Cow, sync::Arc, time::Duration}; + +pub fn init(cx: &mut AppContext) { + // todo!() + // cx.observe_new_views( + // |workspace: &mut Workspace, _: &mut ViewContext| { + // workspace.register_action(ContextMenu::select_first); + // workspace.register_action(ContextMenu::select_last); + // workspace.register_action(ContextMenu::select_next); + // workspace.register_action(ContextMenu::select_prev); + // workspace.register_action(ContextMenu::confirm); + // workspace.register_action(ContextMenu::cancel); + // }, + // ) + // .detach(); +} + +pub type StaticItem = Box AnyElement>; + +type ContextMenuItemBuilder = (); +// todo!() +// Box AnyElement>; + +pub enum ContextMenuItemLabel { + String(Cow<'static, str>), + Element(ContextMenuItemBuilder), +} + +impl From> for ContextMenuItemLabel { + fn from(s: Cow<'static, str>) -> Self { + Self::String(s) + } +} + +impl From<&'static str> for ContextMenuItemLabel { + fn from(s: &'static str) -> Self { + Self::String(s.into()) + } +} + +impl From for ContextMenuItemLabel { + fn from(s: String) -> Self { + Self::String(s.into()) + } +} + +// todo!() +// impl From for ContextMenuItemLabel +// where +// T: 'static + Fn(&mut MouseState, &theme::ContextMenuItem) -> AnyElement, +// { +// fn from(f: T) -> Self { +// Self::Element(Box::new(f)) +// } +// } + +pub enum ContextMenuItemAction { + Action(Box), + Handler(Arc)>), +} + +impl Clone for ContextMenuItemAction { + fn clone(&self) -> Self { + match self { + Self::Action(action) => Self::Action(action.boxed_clone()), + Self::Handler(handler) => Self::Handler(handler.clone()), + } + } +} + +pub enum ContextMenuItem { + Item { + label: ContextMenuItemLabel, + action: ContextMenuItemAction, + }, + Static(StaticItem), + Separator, +} + +impl ContextMenuItem { + pub fn action(label: impl Into, action: impl 'static + Action) -> Self { + Self::Item { + label: label.into(), + action: ContextMenuItemAction::Action(Box::new(action)), + } + } + + pub fn handler( + label: impl Into, + handler: impl 'static + Fn(&mut ViewContext), + ) -> Self { + Self::Item { + label: label.into(), + action: ContextMenuItemAction::Handler(Arc::new(handler)), + } + } + + pub fn separator() -> Self { + Self::Separator + } + + fn is_action(&self) -> bool { + matches!(self, Self::Item { .. }) + } + + fn action_id(&self) -> Option { + match self { + ContextMenuItem::Item { action, .. } => match action { + ContextMenuItemAction::Action(action) => Some(action.type_id()), + ContextMenuItemAction::Handler(_) => None, + }, + ContextMenuItem::Static(..) | ContextMenuItem::Separator => None, + } + } +} + +pub struct ContextMenu { + show_count: usize, + anchor_position: Point, + anchor_corner: AnchorCorner, + // todo!() + // position_mode: OverlayPositionMode, + items: Vec, + selected_index: Option, + visible: bool, + delay_cancel: bool, + previously_focused_view_handle: Option, + parent_view_id: EntityId, + focus_handle: FocusHandle, + // todo!() + // _actions_observation: Subscription, +} + +impl FocusableView for ContextMenu { + fn focus_handle(&self, _: &AppContext) -> FocusHandle { + self.focus_handle.clone() + } +} + +// todo!() +// fn ui_name() -> &'static str { +// "ContextMenu" +// } +// +// fn update_keymap_context(&self, keymap: &mut KeymapContext, _: &AppContext) { +// Self::reset_to_default_keymap_context(keymap); +// keymap.add_identifier("menu"); +// } +// +// fn focus_out(&mut self, _: AnyView, cx: &mut ViewContext) { +// self.reset(cx); +// } + +impl Render for ContextMenu { + type Element = Div; + + fn render(&mut self, cx: &mut ViewContext) -> Self::Element { + if !self.visible { + return div(); + } + + // todo!() + // // Render the menu once at minimum width. + // let mut collapsed_menu = self.render_menu_for_measurement(cx); + // let expanded_menu = self + // .render_menu(cx) + // .dynamically(move |constraint, view, cx| { + // SizeConstraint::strict_along( + // Axis::Horizontal, + // collapsed_menu.layout(constraint, view, cx).0.x(), + // ) + // }); + + // Overlay::new(expanded_menu) + // .with_hoverable(true) + // .with_fit_mode(OverlayFitMode::SnapToWindow) + // .with_anchor_position(self.anchor_position) + // .with_anchor_corner(self.anchor_corner) + // .with_position_mode(self.position_mode) + // .into_any() + div() + } +} + +impl ContextMenu { + pub fn new(parent_view_id: EntityId, cx: &mut ViewContext) -> Self { + Self { + show_count: 0, + delay_cancel: false, + anchor_position: Default::default(), + anchor_corner: AnchorCorner::TopLeft, + // todo!() + // position_mode: OverlayPositionMode::Window, + items: Default::default(), + selected_index: Default::default(), + visible: Default::default(), + previously_focused_view_handle: None, + parent_view_id, + // todo!() + // _actions_observation: cx.observe_actions(Self::action_dispatched), + focus_handle: cx.focus_handle(), + } + } + + pub fn visible(&self) -> bool { + self.visible + } + + 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_executor() + .timer(Duration::from_millis(50)) + .await; + this.update(&mut cx, |this, cx| this.cancel(&Default::default(), cx))?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + } + + 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) { + match action { + ContextMenuItemAction::Action(action) => { + let window = cx.window(); + let view_id = self.parent_view_id; + let action = action.boxed_clone(); + // todo!() + // cx.app_context() + // .spawn(|mut cx| async move { + // window + // .dispatch_action(view_id, action.as_ref(), &mut cx) + // .ok_or_else(|| anyhow!("window was closed")) + // }) + // .detach_and_log_err(cx); + } + ContextMenuItemAction::Handler(handler) => handler(cx), + } + self.reset(cx); + } + } + } + + pub fn delay_cancel(&mut self) { + self.delay_cancel = true; + } + + fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext) { + if !self.delay_cancel { + self.reset(cx); + let show_count = self.show_count; + cx.defer(move |this, cx| { + if this.focus_handle.is_focused(cx) && this.show_count == show_count { + if let Some(previously_focused_view_handle) = + this.previously_focused_view_handle.take() + { + previously_focused_view_handle.focus(cx); + } + } + }); + } else { + self.delay_cancel = false; + } + } + + 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_action()); + cx.notify(); + } + + fn select_last(&mut self, _: &SelectLast, cx: &mut ViewContext) { + for (ix, item) in self.items.iter().enumerate().rev() { + if item.is_action() { + 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_action() { + 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_action() { + self.selected_index = Some(ix); + cx.notify(); + break; + } + } + } else { + self.select_last(&Default::default(), cx); + } + } + + pub fn toggle( + &mut self, + anchor_position: Point, + anchor_corner: AnchorCorner, + items: Vec, + cx: &mut ViewContext, + ) { + if self.visible() { + self.cancel(&Cancel, cx); + } else { + let mut items = items.into_iter().peekable(); + if items.peek().is_some() { + self.items = items.collect(); + self.anchor_position = anchor_position; + self.anchor_corner = anchor_corner; + self.visible = true; + self.show_count += 1; + if !self.focus_handle.is_focused(cx) { + self.previously_focused_view_handle = cx.focused(); + } + cx.focus_self(); + } else { + self.visible = false; + } + } + cx.notify(); + } + + pub fn show( + &mut self, + anchor_position: Point, + anchor_corner: AnchorCorner, + items: Vec, + cx: &mut ViewContext, + ) { + let mut items = items.into_iter().peekable(); + if items.peek().is_some() { + self.items = items.collect(); + self.anchor_position = anchor_position; + self.anchor_corner = anchor_corner; + self.visible = true; + self.show_count += 1; + if !self.focus_handle.is_focused(cx) { + self.previously_focused_view_handle = cx.focused(); + } + cx.focus_self(); + } else { + self.visible = false; + } + cx.notify(); + } + + // todo!() + // pub fn set_position_mode(&mut self, mode: OverlayPositionMode) { + // self.position_mode = mode; + // } + + fn render_menu_for_measurement(&self, cx: &mut ViewContext) -> Div { + // let style = theme::current(cx).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.in_state(self.selected_index == Some(ix)); + // let style = style.style_for(&mut Default::default()); + + // match label { + // ContextMenuItemLabel::String(label) => { + // Label::new(label.to_string(), style.label.clone()) + // .contained() + // .with_style(style.container) + // .into_any() + // } + // ContextMenuItemLabel::Element(element) => { + // element(&mut Default::default(), style) + // } + // } + // } + + // ContextMenuItem::Static(f) => f(cx), + + // ContextMenuItem::Separator => Empty::new() + // .collapsed() + // .contained() + // .with_style(style.separator) + // .constrained() + // .with_height(1.) + // .into_any(), + // } + // })), + // ) + // .with_child( + // Flex::column() + // .with_children(self.items.iter().enumerate().map(|(ix, item)| { + // match item { + // ContextMenuItem::Item { action, .. } => { + // let style = style.item.in_state(self.selected_index == Some(ix)); + // let style = style.style_for(&mut Default::default()); + + // match action { + // ContextMenuItemAction::Action(action) => KeystrokeLabel::new( + // self.parent_view_id, + // action.boxed_clone(), + // style.keystroke.container, + // style.keystroke.text.clone(), + // ) + // .into_any(), + // ContextMenuItemAction::Handler(_) => Empty::new().into_any(), + // } + // } + + // ContextMenuItem::Static(_) => Empty::new().into_any(), + + // ContextMenuItem::Separator => Empty::new() + // .collapsed() + // .constrained() + // .with_height(1.) + // .contained() + // .with_style(style.separator) + // .into_any(), + // } + // })) + // .contained() + // .with_margin_left(style.keystroke_margin), + // ) + // .contained() + // .with_style(style.container) + todo!() + } + + fn render_menu(&self, cx: &mut ViewContext) -> Div { + enum Menu {} + enum MenuItem {} + + // let style = theme::current(cx).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.clone(); + // let view_id = self.parent_view_id; + // MouseEventHandler::new::(ix, cx, |state, _| { + // let style = style.item.in_state(self.selected_index == Some(ix)); + // let style = style.style_for(state); + // let keystroke = match &action { + // ContextMenuItemAction::Action(action) => Some( + // KeystrokeLabel::new( + // view_id, + // action.boxed_clone(), + // style.keystroke.container, + // style.keystroke.text.clone(), + // ) + // .flex_float(), + // ), + // ContextMenuItemAction::Handler(_) => None, + // }; + + // Flex::row() + // .with_child(match label { + // ContextMenuItemLabel::String(label) => { + // Label::new(label.clone(), style.label.clone()) + // .contained() + // .into_any() + // } + // ContextMenuItemLabel::Element(element) => { + // element(state, style) + // } + // }) + // .with_children(keystroke) + // .contained() + // .with_style(style.container) + // }) + // .with_cursor_style(CursorStyle::PointingHand) + // .on_up(MouseButton::Left, |_, _, _| {}) // Capture these events + // .on_down(MouseButton::Left, |_, _, _| {}) // Capture these events + // .on_click(MouseButton::Left, move |_, menu, cx| { + // menu.cancel(&Default::default(), cx); + // let window = cx.window(); + // match &action { + // ContextMenuItemAction::Action(action) => { + // let action = action.boxed_clone(); + // cx.app_context() + // .spawn(|mut cx| async move { + // window + // .dispatch_action( + // view_id, + // action.as_ref(), + // &mut cx, + // ) + // .ok_or_else(|| anyhow!("window was closed")) + // }) + // .detach_and_log_err(cx); + // } + // ContextMenuItemAction::Handler(handler) => handler(cx), + // } + // }) + // .on_drag(MouseButton::Left, |_, _, _| {}) + // .into_any() + // } + + // ContextMenuItem::Static(f) => f(cx), + + // ContextMenuItem::Separator => Empty::new() + // .constrained() + // .with_height(1.) + // .contained() + // .with_style(style.separator) + // .into_any(), + // } + // })) + // .contained() + // .with_style(style.container) + // }) + // .on_down_out(MouseButton::Left, |_, this, cx| { + // this.cancel(&Default::default(), cx); + // }) + // .on_down_out(MouseButton::Right, |_, this, cx| { + // this.cancel(&Default::default(), cx); + // }) + todo!() + } +} diff --git a/crates/terminal_view2/Cargo.toml b/crates/terminal_view2/Cargo.toml index f0d2e6ccf0..714ccb60a2 100644 --- a/crates/terminal_view2/Cargo.toml +++ b/crates/terminal_view2/Cargo.toml @@ -9,7 +9,7 @@ path = "src/terminal_view.rs" doctest = false [dependencies] -# context_menu = { package = "context_menu2", path = "../context_menu2" } +context_menu = { package = "context_menu2", path = "../context_menu2" } editor = { package = "editor2", path = "../editor2" } language = { package = "language2", path = "../language2" } gpui = { package = "gpui2", path = "../gpui2" } diff --git a/crates/terminal_view2/src/terminal_element.rs b/crates/terminal_view2/src/terminal_element.rs index bc9ca5d0e8..e93d82047d 100644 --- a/crates/terminal_view2/src/terminal_element.rs +++ b/crates/terminal_view2/src/terminal_element.rs @@ -23,6 +23,7 @@ // TerminalSize, // }; // use theme::ThemeSettings; +// use workspace::ElementId; // use std::mem; // use std::{fmt::Debug, ops::RangeInclusive}; @@ -809,7 +810,7 @@ // }); // } -// fn element_id(&self) -> Option { +// fn element_id(&self) -> Option { // todo!() // } diff --git a/crates/terminal_view2/src/terminal_view.rs b/crates/terminal_view2/src/terminal_view.rs index 55b7c1af66..ad47e720e2 100644 --- a/crates/terminal_view2/src/terminal_view.rs +++ b/crates/terminal_view2/src/terminal_view.rs @@ -7,27 +7,18 @@ pub mod terminal_panel; // todo!() // use crate::terminal_element::TerminalElement; -use anyhow::Context; -use dirs::home_dir; +use context_menu::{ContextMenu, ContextMenuItem}; use editor::{scroll::autoscroll::Autoscroll, Editor}; use gpui::{ - actions, div, img, red, register_action, AnyElement, AppContext, Component, DispatchPhase, Div, - EventEmitter, FocusEvent, FocusHandle, Focusable, FocusableComponent, FocusableView, - InputHandler, InteractiveComponent, KeyDownEvent, Keystroke, Model, ParentComponent, Pixels, - Render, SharedString, Styled, Task, View, ViewContext, VisualContext, WeakView, + actions, div, img, red, register_action, AnchorCorner, AnyElement, AppContext, Component, + DispatchPhase, Div, EventEmitter, FocusEvent, FocusHandle, Focusable, FocusableComponent, + FocusableView, InputHandler, InteractiveComponent, KeyDownEvent, Keystroke, Model, + ParentComponent, Pixels, Render, SharedString, Styled, Task, View, ViewContext, VisualContext, + WeakView, }; use language::Bias; use persistence::TERMINAL_DB; use project::{search::SearchQuery, LocalWorktree, Project}; -use serde::Deserialize; -use settings::Settings; -use smol::Timer; -use std::{ - ops::RangeInclusive, - path::{Path, PathBuf}, - sync::Arc, - time::Duration, -}; use terminal::{ alacritty_terminal::{ index::Point, @@ -42,7 +33,20 @@ use workspace::{ notifications::NotifyResultExt, register_deserializable_item, searchable::{SearchEvent, SearchOptions, SearchableItem}, - NewCenterTerminal, Pane, ToolbarItemLocation, Workspace, WorkspaceId, + CloseActiveItem, NewCenterTerminal, Pane, ToolbarItemLocation, Workspace, WorkspaceId, +}; + +use anyhow::Context; +use dirs::home_dir; +use serde::Deserialize; +use settings::Settings; +use smol::Timer; + +use std::{ + ops::RangeInclusive, + path::{Path, PathBuf}, + sync::Arc, + time::Duration, }; const CURSOR_BLINK_INTERVAL: Duration = Duration::from_millis(500); @@ -82,7 +86,7 @@ pub struct TerminalView { has_new_content: bool, //Currently using iTerm bell, show bell emoji in tab until input is received has_bell: bool, - // context_menu: View, + context_menu: View, blink_state: bool, blinking_on: bool, blinking_paused: bool, @@ -265,8 +269,7 @@ impl TerminalView { has_new_content: true, has_bell: false, focus_handle: cx.focus_handle(), - // todo!() - // context_menu: cx.build_view(|cx| ContextMenu::new(view_id, cx)), + context_menu: cx.build_view(|cx| ContextMenu::new(view_id, cx)), blink_state: true, blinking_on: false, blinking_paused: false, @@ -293,18 +296,21 @@ impl TerminalView { cx.emit(Event::Wakeup); } - pub fn deploy_context_menu(&mut self, _position: Point, _cx: &mut ViewContext) { - //todo!(context_menu) - // let menu_entries = vec![ - // ContextMenuItem::action("Clear", Clear), - // ContextMenuItem::action("Close", pane::CloseActiveItem { save_intent: None }), - // ]; + pub fn deploy_context_menu( + &mut self, + position: gpui::Point, + cx: &mut ViewContext, + ) { + let menu_entries = vec![ + ContextMenuItem::action("Clear", Clear), + ContextMenuItem::action("Close", CloseActiveItem { save_intent: None }), + ]; - // self.context_menu.update(cx, |menu, cx| { - // menu.show(position, AnchorCorner::TopLeft, menu_entries, cx) - // }); + self.context_menu.update(cx, |menu, cx| { + menu.show(position, AnchorCorner::TopLeft, menu_entries, cx) + }); - // cx.notify(); + cx.notify(); } fn show_character_palette(&mut self, _: &ShowCharacterPalette, cx: &mut ViewContext) { @@ -561,8 +567,7 @@ impl Render for TerminalView { // self.can_navigate_to_selected_word, // ) ) - // todo!() - // .child(ChildView::new(&self.context_menu, cx)) + .child(self.context_menu.clone()) } } diff --git a/crates/zed2/Cargo.toml b/crates/zed2/Cargo.toml index f471ea2306..398a7a8920 100644 --- a/crates/zed2/Cargo.toml +++ b/crates/zed2/Cargo.toml @@ -27,7 +27,7 @@ collab_ui = { package = "collab_ui2", path = "../collab_ui2" } collections = { path = "../collections" } command_palette = { package="command_palette2", path = "../command_palette2" } # component_test = { path = "../component_test" } -# context_menu = { path = "../context_menu" } +context_menu = { package = "context_menu2", path = "../context_menu2" } client = { package = "client2", path = "../client2" } # clock = { path = "../clock" } copilot = { package = "copilot2", path = "../copilot2" } diff --git a/crates/zed2/src/main.rs b/crates/zed2/src/main.rs index ee1a067a29..581913b752 100644 --- a/crates/zed2/src/main.rs +++ b/crates/zed2/src/main.rs @@ -141,7 +141,7 @@ fn main() { cx.set_global(client.clone()); theme::init(cx); - // context_menu::init(cx); + context_menu::init(cx); project::Project::init(&client, cx); client::init(&client, cx); command_palette::init(cx);