diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index c8b8648e..ef7a8d05 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -3,15 +3,22 @@ use std::sync::Arc; use anyhow::Result; use app_state::AppState; use assets::Assets; -use gpui::{App, AppContext}; +use gpui::{actions, App, AppContext, KeyBinding, Menu, MenuItem}; mod app_state; mod assets; mod story_workspace; +actions!(main_menu, [Quit, Copy]); + fn init(app_state: Arc, cx: &mut AppContext) -> Result<()> { story_workspace::init(app_state.clone(), cx); + cx.bind_keys([ + KeyBinding::new("cmd-q", Quit, None), + KeyBinding::new("cmd-c", Copy, None), + ]); + Ok(()) } @@ -28,9 +35,26 @@ fn main() { return; } + cx.on_action(quit); + + cx.set_menus(vec![ + Menu { + name: "GPUI App", + items: vec![MenuItem::action("Quit", Quit)], + }, + Menu { + name: "Edit", + items: vec![MenuItem::action("Copy", Copy)], + }, + ]); + story_workspace::open_new(app_state.clone(), cx, |_workspace, _cx| { // do something }) .detach(); }); } + +fn quit(_: &Quit, _cx: &mut AppContext) { + std::process::exit(0); +} diff --git a/crates/story/src/input_story.rs b/crates/story/src/input_story.rs index 912e2f26..8396a42f 100644 --- a/crates/story/src/input_story.rs +++ b/crates/story/src/input_story.rs @@ -4,7 +4,7 @@ use gpui::{ WindowContext, }; -use ui::{button::Button, h_flex, input::TextInput, v_flex, FocusableCycle, IconName}; +use ui::{button::Button, h_flex, input::TextInput, v_flex, Clickable, FocusableCycle, IconName}; use crate::section; @@ -18,7 +18,6 @@ pub fn init(cx: &mut AppContext) { } pub struct InputStory { - focus_handle: FocusHandle, input1: View, input2: View, mash_input: View, @@ -65,7 +64,6 @@ impl InputStory { }); Self { - focus_handle: cx.focus_handle(), input1, input2: cx.new_view(|cx| { let mut input = TextInput::new(cx); @@ -116,17 +114,10 @@ impl FocusableCycle for InputStory { } } -impl FocusableView for InputStory { - fn focus_handle(&self, cx: &gpui::AppContext) -> FocusHandle { - self.focus_handle.clone() - } -} - impl Render for InputStory { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { v_flex() .key_context("InputStory") - .track_focus(&self.focus_handle) .on_action(cx.listener(Self::tab)) .on_action(cx.listener(Self::tab_prev)) .size_full() @@ -158,7 +149,8 @@ impl Render for InputStory { Button::new("btn-submit", cx) .w_full() .style(ui::button::ButtonStyle::Primary) - .label("Submit"), + .label("Submit") + .on_click(cx.listener(|_, _, cx| cx.dispatch_action(Box::new(Tab)))), ) .child( Button::new("btn-cancel", cx) diff --git a/crates/story/src/lib.rs b/crates/story/src/lib.rs index d7d2b68f..e7bc6459 100644 --- a/crates/story/src/lib.rs +++ b/crates/story/src/lib.rs @@ -203,7 +203,7 @@ impl StoryContainer { } impl Render for StoryContainer { - fn render(&mut self, _: &mut ViewContext) -> impl IntoElement { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { div() .id("story-container") .overflow_y_scroll() diff --git a/crates/story/src/list_story.rs b/crates/story/src/list_story.rs index 163a2ee1..653ba3ce 100644 --- a/crates/story/src/list_story.rs +++ b/crates/story/src/list_story.rs @@ -186,7 +186,7 @@ impl ListStory { } fn new(cx: &mut ViewContext) -> Self { - let companies = (0..10_000) + let companies = (0..1_000) .map(|_| random_company()) .collect::>(); @@ -268,10 +268,8 @@ impl Render for ListStory { v_flex() .h_full() .w_full() - .border_1() + .border_r_1() .border_color(cx.theme().border) - .rounded_md() - .occlude() .child(self.company_list.clone()), ) // .child( diff --git a/crates/story/src/picker_story.rs b/crates/story/src/picker_story.rs index e8beeb9f..69711431 100644 --- a/crates/story/src/picker_story.rs +++ b/crates/story/src/picker_story.rs @@ -1,5 +1,5 @@ use gpui::{ - actions, div, prelude::FluentBuilder as _, px, InteractiveElement as _, IntoElement, + deferred, div, hsla, prelude::FluentBuilder as _, px, InteractiveElement as _, IntoElement, ParentElement, Render, Styled, Task, View, ViewContext, VisualContext as _, WeakView, WindowContext, }; @@ -9,11 +9,10 @@ use ui::{ h_flex, list::ListItem, picker::{Picker, PickerDelegate}, - v_flex, Clickable as _, IconName, + theme::hsl, + v_flex, Clickable as _, IconName, StyledExt, }; -actions!(picker_story, [DismissPicker]); - pub struct ListItemDeletegate { story: WeakView, selected_index: usize, @@ -200,7 +199,6 @@ impl Render for PickerStory { Button::new("show-picker", cx) .label("Show Picker...") .icon(IconName::Search) - .style(ButtonStyle::Primary) .on_click(cx.listener(|this, _, cx| { this.open = !this.open; this.picker.focus_handle(cx).focus(cx); @@ -213,21 +211,24 @@ impl Render for PickerStory { h_flex() .gap_1() .child("You have selected:") - .child(selected_value), + .child(div().child(selected_value).text_color(gpui::red())), ) }) .when(self.open, |this| { - this.child( + this.child(deferred( div().absolute().size_full().top_0().left_0().child( - v_flex().top_10().flex().flex_col().items_center().child( + v_flex().flex().flex_col().items_center().child( div() .w(px(450.)) .h(px(350.)) - .occlude() - .child(self.picker.clone()), + .child(self.picker.clone()) + .on_mouse_down_out(cx.listener(|this, _, cx| { + this.open = false; + cx.notify(); + })), ), ), - ) + )) }) } } diff --git a/crates/story/src/popover_story.rs b/crates/story/src/popover_story.rs index 5009da0e..5edc1f97 100644 --- a/crates/story/src/popover_story.rs +++ b/crates/story/src/popover_story.rs @@ -1,7 +1,7 @@ use gpui::{ - div, px, AnchorCorner, AppContext, DismissEvent, Element, EventEmitter, FocusHandle, - FocusableView, IntoElement, MouseButton, ParentElement as _, Render, Styled as _, View, - ViewContext, VisualContext, WindowContext, + actions, div, impl_actions, px, AnchorCorner, AppContext, DismissEvent, Element, EventEmitter, + FocusHandle, FocusableView, InteractiveElement, IntoElement, MouseButton, MouseDownEvent, + ParentElement as _, Render, Styled as _, View, ViewContext, VisualContext, WindowContext, }; use ui::{ button::{Button, ButtonSize}, @@ -9,9 +9,12 @@ use ui::{ h_flex, input::TextInput, popover::{Popover, PopoverContent}, - v_flex, Clickable, + popup_menu::PopupMenu, + v_flex, Clickable, IconName, }; +actions!(popover_story, [Copy, Paste, Cut, SearchAll]); + struct Form { input1: View, } @@ -36,6 +39,7 @@ impl Render for Form { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { v_flex() .gap_4() + .p_4() .size_full() .child("This is a form container.") .child(self.input1.clone()) @@ -47,6 +51,7 @@ impl Render for Form { } pub struct PopoverStory { + focus_handle: FocusHandle, form: View
, } @@ -57,17 +62,56 @@ impl PopoverStory { fn new(cx: &mut ViewContext) -> Self { let form = Form::new(cx); - Self { form } + Self { + form, + focus_handle: cx.focus_handle(), + } + } + + fn on_copy(&mut self, _: &Copy, _: &mut ViewContext) { + println!("You have clicked copy"); + } + fn on_cut(&mut self, _: &Cut, _: &mut ViewContext) { + println!("You have clicked cut"); + } + fn on_paste(&mut self, _: &Paste, _: &mut ViewContext) { + println!("You have clicked paste"); + } + fn on_search_all(&mut self, _: &SearchAll, _: &mut ViewContext) { + println!("You have clicked SearchAll"); + } +} + +impl FocusableView for PopoverStory { + fn focus_handle(&self, _cx: &AppContext) -> FocusHandle { + self.focus_handle.clone() } } impl Render for PopoverStory { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { let form = self.form.clone(); + let _focused = self.focus_handle.is_focused(cx); + let focus_handle = self.focus_handle.clone(); v_flex() + .track_focus(&self.focus_handle) + .on_action(cx.listener(Self::on_copy)) + .on_action(cx.listener(Self::on_cut)) + .on_action(cx.listener(Self::on_paste)) + .on_action(cx.listener(Self::on_search_all)) .p_4() .size_full() + .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, cx| { + cx.focus(&this.focus_handle); + })) + .child( + Button::new("test1", cx) + .label("Hello") + .on_click(move |_, cx| { + cx.dispatch_action(Box::new(Copy)); + }), + ) .gap_6() .child( h_flex() @@ -115,6 +159,29 @@ impl Render for PopoverStory { }), ), ) + .child( + h_flex().child( + Popover::new("popup-menu") + .trigger(Button::new("popup-menu-1", cx).icon(IconName::Info)) + .content(move |cx| { + let focus_handle = focus_handle.clone(); + PopupMenu::build(cx, |mut this, _| { + this.content(focus_handle) + .menu("Copy", Box::new(Copy)) + .menu("Cut", Box::new(Cut)) + .menu("Paste", Box::new(Paste)) + .separator() + .menu_with_icon( + IconName::Search, + "Search", + Box::new(SearchAll), + ); + + this + }) + }), + ), + ) .child( div().absolute().bottom_4().left_0().w_full().h_10().child( h_flex() diff --git a/crates/ui/src/icon.rs b/crates/ui/src/icon.rs index 9e05e871..f514f481 100644 --- a/crates/ui/src/icon.rs +++ b/crates/ui/src/icon.rs @@ -4,7 +4,7 @@ use gpui::{ SharedString, StyleRefinement, Styled, Svg, View, VisualContext, WindowContext, }; -#[derive(IntoElement)] +#[derive(IntoElement, Clone)] pub enum IconName { Check, Minus, @@ -77,15 +77,27 @@ pub struct Icon { size: ButtonSize, } -impl Icon { - pub fn new(name: IconName) -> Self { +impl Default for Icon { + fn default() -> Self { Self { base: svg().flex_none().size_4(), - path: name.path(), + path: "".into(), text_color: None, size: ButtonSize::Medium, } } +} + +impl Clone for Icon { + fn clone(&self) -> Self { + Self::default().path(self.path.clone()).size(self.size) + } +} + +impl Icon { + pub fn new(name: IconName) -> Self { + Self::default().path(name.path()) + } /// Set the icon path of the Assets bundle /// diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index fd4d1ff7..00b7ca7a 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -22,6 +22,7 @@ pub mod input; pub mod list; pub mod picker; pub mod popover; +pub mod popup_menu; pub mod switch; pub mod tab; pub mod tooltip; @@ -42,4 +43,5 @@ pub fn init(cx: &mut gpui::AppContext) { picker::init(cx); dropdown::init(cx); popover::init(cx); + popup_menu::init(cx); } diff --git a/crates/ui/src/list/list_item.rs b/crates/ui/src/list/list_item.rs index d285c4c5..b1937840 100644 --- a/crates/ui/src/list/list_item.rs +++ b/crates/ui/src/list/list_item.rs @@ -1,6 +1,6 @@ use gpui::{ div, prelude::FluentBuilder as _, ClickEvent, Div, ElementId, InteractiveElement, IntoElement, - MouseButton, MouseDownEvent, ParentElement, RenderOnce, Stateful, + MouseButton, MouseDownEvent, ParentElement, Pixels, RenderOnce, Stateful, StatefulInteractiveElement as _, Styled, WindowContext, }; @@ -12,6 +12,7 @@ pub struct ListItem { disabled: bool, selected: bool, check_icon: Option, + border_radius: Option, on_click: Option>, on_secondary_mouse_down: Option>, } @@ -25,6 +26,7 @@ impl ListItem { on_click: None, on_secondary_mouse_down: None, check_icon: None, + border_radius: None, } } @@ -43,6 +45,11 @@ impl ListItem { self } + pub fn rounded(mut self, r: impl Into) -> Self { + self.border_radius = Some(r.into()); + self + } + pub fn on_click(mut self, handler: impl Fn(&ClickEvent, &mut WindowContext) + 'static) -> Self { self.on_click = Some(Box::new(handler)); self @@ -93,16 +100,25 @@ impl RenderOnce for ListItem { .justify_between() .text_base() .text_color(cx.theme().foreground) + .when_some(self.border_radius, |this, r| this.rounded(r)) .when_some(self.on_click, |this, on_click| { - this.cursor_pointer().on_click(on_click) + if !self.disabled { + this.cursor_pointer().on_click(on_click) + } else { + this + } }) .when(self.selected, |this| this.bg(cx.theme().accent)) - .when(!self.selected, |this| { + .when(!self.selected && !self.disabled, |this| { this.hover(|this| this.bg(cx.theme().accent)) }) // Right click .when_some(self.on_secondary_mouse_down, |this, on_mouse_down| { - this.on_mouse_down(MouseButton::Right, move |ev, cx| (on_mouse_down)(ev, cx)) + if !self.disabled { + this.on_mouse_down(MouseButton::Right, move |ev, cx| (on_mouse_down)(ev, cx)) + } else { + this + } }) .child(self.base.w_full()) .when(self.selected, |this| { diff --git a/crates/ui/src/picker.rs b/crates/ui/src/picker.rs index 394b5b6d..5f8bfbeb 100644 --- a/crates/ui/src/picker.rs +++ b/crates/ui/src/picker.rs @@ -105,10 +105,6 @@ pub trait PickerDelegate: Sized + 'static { cx: &mut ViewContext>, ) -> Option; - fn separators_after_indices(&self) -> Vec { - Vec::new() - } - fn update_matches(&mut self, _query: &str, _cx: &mut ViewContext>) -> Task<()> { Task::ready(()) } @@ -332,15 +328,6 @@ impl Picker { self.delegate .render_item(ix, ix == self.delegate.selected_index(), cx), ) - .when( - self.delegate.separators_after_indices().contains(&ix), - |picker| { - picker - .border_color(cx.theme().border) - .border_b_1() - .pb(px(-1.0)) - }, - ) } fn render_scrollbar(&self, cx: &mut ViewContext) -> Option { @@ -547,8 +534,8 @@ impl Render for Picker { .id("picker") .key_context("Picker") .group("picker-group") - .size_full() .track_focus(&focus_handle) + .size_full() .when_some(self.width, |el, width| el.w(width)) .overflow_hidden() .when(self.is_modal, |this| this.elevation_3(cx)) @@ -608,8 +595,5 @@ impl Render for Picker { .child("No matched."), ) }) - .on_mouse_down_out(cx.listener(|_, _, cx| { - cx.dispatch_action(Box::new(Cancel)); - })) } } diff --git a/crates/ui/src/popover.rs b/crates/ui/src/popover.rs index ef989fe8..e81ea4eb 100644 --- a/crates/ui/src/popover.rs +++ b/crates/ui/src/popover.rs @@ -2,15 +2,13 @@ use std::{cell::RefCell, rc::Rc}; use crate::{theme::ActiveTheme, Selectable, StyledExt as _}; use gpui::{ - actions, anchored, deferred, div, prelude::FluentBuilder, AnchorCorner, AnyElement, AppContext, - Bounds, DismissEvent, DispatchPhase, Element, ElementId, EventEmitter, FocusHandle, - FocusableView, GlobalElementId, Hitbox, InteractiveElement, IntoElement, LayoutId, ManagedView, - MouseButton, MouseDownEvent, ParentElement as _, Pixels, Point, Render, Style, Styled as _, - View, ViewContext, VisualContext, WindowContext, + anchored, deferred, div, prelude::FluentBuilder, AnchorCorner, AnyElement, AppContext, Bounds, + DismissEvent, DispatchPhase, Element, ElementId, EventEmitter, FocusHandle, FocusableView, + GlobalElementId, Hitbox, InteractiveElement, IntoElement, LayoutId, ManagedView, MouseButton, + MouseDownEvent, ParentElement as _, Pixels, Point, Render, Style, Styled as _, View, + ViewContext, VisualContext, WindowContext, }; -actions!(popover, [Open, Dismiss]); - pub fn init(_cx: &AppContext) {} pub struct PopoverContent { @@ -43,7 +41,7 @@ impl FocusableView for PopoverContent { impl Render for PopoverContent { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - div().child(self.content.clone()(cx)) + div().p_4().max_w_128().child(self.content.clone()(cx)) } } @@ -209,17 +207,14 @@ impl Element for Popover { anchored.child( div() .occlude() - .map(|d| match this.anchor { - AnchorCorner::TopLeft | AnchorCorner::TopRight => d.mt_2(), - AnchorCorner::BottomLeft | AnchorCorner::BottomRight => d.mb_2(), - }) .elevation_2(cx) .bg(cx.theme().popover) .border_1() .border_color(cx.theme().border) - .p_4() - .max_w_128() - .occlude() + .map(|d| match this.anchor { + AnchorCorner::TopLeft | AnchorCorner::TopRight => d.mt_2(), + AnchorCorner::BottomLeft | AnchorCorner::BottomRight => d.mb_2(), + }) .on_mouse_down_out(move |_, cx| { // Update the element_state.content_view to `None`, // so that the `paint`` method will not paint it. diff --git a/crates/ui/src/popup_menu.rs b/crates/ui/src/popup_menu.rs new file mode 100644 index 00000000..d0bd2177 --- /dev/null +++ b/crates/ui/src/popup_menu.rs @@ -0,0 +1,222 @@ +use std::rc::Rc; + +use gpui::{ + actions, div, prelude::FluentBuilder, px, Action, AppContext, DismissEvent, EventEmitter, + FocusHandle, FocusableView, InteractiveElement, KeyBinding, ParentElement, Render, + SharedString, Styled as _, View, ViewContext, VisualContext as _, WindowContext, +}; + +use crate::{h_flex, list::ListItem, theme::ActiveTheme, v_flex, Icon, StyledExt as _}; + +actions!(menu, [Confirm, Dismiss, SelectNext, SelectPrev]); + +pub fn init(cx: &mut AppContext) { + let context = Some("PopupMenu"); + cx.bind_keys([ + KeyBinding::new("enter", Confirm, context), + KeyBinding::new("escape", Dismiss, context), + KeyBinding::new("up", SelectPrev, context), + KeyBinding::new("down", SelectNext, context), + ]); +} + +enum PopupMenuItem { + Separator, + Item { + icon: Option, + label: SharedString, + action: Option>, + handler: Rc, &mut WindowContext)>, + }, +} + +impl PopupMenuItem { + fn is_clickable(&self) -> bool { + !matches!(self, PopupMenuItem::Separator) + } +} + +pub struct PopupMenu { + focus_handle: FocusHandle, + action_context: Option, + menu_items: Vec, + selected_index: Option, + _subscriptions: [gpui::Subscription; 1], +} + +impl PopupMenu { + pub fn build( + cx: &mut WindowContext, + f: impl FnOnce(Self, &mut WindowContext) -> Self, + ) -> View { + cx.new_view(|cx| { + let focus_handle = cx.focus_handle(); + let _on_blur_subscription = cx.on_blur(&focus_handle, |this: &mut PopupMenu, cx| { + this.dismiss(&Dismiss, cx) + }); + + let menu = Self { + focus_handle, + action_context: None, + menu_items: Vec::new(), + selected_index: None, + _subscriptions: [_on_blur_subscription], + }; + cx.refresh(); + f(menu, cx) + }) + } + + /// You must set content (FocusHandle) with the parent view, if the menu action is listening on the parent view. + /// When the Menu Item confirmed, the parent view will be focused again to ensure to receive the action. + pub fn content(&mut self, content: FocusHandle) -> &mut Self { + self.action_context = Some(content); + self + } + + /// Add Menu Item + pub fn menu(&mut self, label: impl Into, action: Box) -> &mut Self { + self.add_menu_item(None, label, action) + } + + /// Add Menu Item with Icon + pub fn menu_with_icon( + &mut self, + icon: impl Into, + label: impl Into, + action: Box, + ) -> &mut Self { + self.add_menu_item(Some(icon.into()), label, action) + } + + fn add_menu_item( + &mut self, + icon: Option, + label: impl Into, + action: Box, + ) -> &mut Self { + self.menu_items.push(PopupMenuItem::Item { + icon, + label: label.into(), + action: Some(action.boxed_clone()), + handler: Rc::new(move |content, cx| { + if let Some(content) = &content { + cx.focus(content); + } + cx.dispatch_action(action.boxed_clone()); + }), + }); + self + } + + /// Add a separator Menu Item + pub fn separator(&mut self) -> &mut Self { + self.menu_items.push(PopupMenuItem::Separator); + + self + } + + fn clickable_menu_items(&self) -> impl Iterator { + self.menu_items + .iter() + .enumerate() + .filter(|(_, item)| item.is_clickable()) + } + + fn on_click(&mut self, ix: usize, cx: &mut ViewContext) { + cx.stop_propagation(); + cx.prevent_default(); + self.selected_index = Some(ix); + self.confirm(&Confirm, cx) + } + + fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext) { + let content = self.action_context.as_ref(); + match self.selected_index { + Some(index) => { + let item = self.menu_items.get(index); + match item { + Some(PopupMenuItem::Item { handler, .. }) => { + handler(content, cx); + } + _ => {} + } + } + _ => {} + } + } + + fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext) { + let count = self.clickable_menu_items().count(); + if count > 0 { + let ix = self + .selected_index + .map(|index| if index == count - 1 { 0 } else { index + 1 }) + .unwrap_or(0); + + self.selected_index = Some(ix); + cx.notify(); + } + } + + fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext) { + let count = self.clickable_menu_items().count(); + if count > 0 { + let ix = self + .selected_index + .map(|index| if index == count - 1 { 0 } else { index - 1 }) + .unwrap_or(count - 1); + self.selected_index = Some(ix); + cx.notify(); + } + } + + fn dismiss(&mut self, _: &Dismiss, cx: &mut ViewContext) { + cx.emit(DismissEvent); + } +} + +impl FluentBuilder for PopupMenu {} +impl EventEmitter for PopupMenu {} +impl FocusableView for PopupMenu { + fn focus_handle(&self, _cx: &gpui::AppContext) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for PopupMenu { + fn render(&mut self, cx: &mut gpui::ViewContext) -> impl gpui::IntoElement { + v_flex() + .key_context("PopupMenu") + .track_focus(&self.focus_handle) + .on_action(cx.listener(Self::select_next)) + .on_action(cx.listener(Self::select_prev)) + .on_action(cx.listener(Self::confirm)) + .on_action(cx.listener(Self::dismiss)) + .on_mouse_down_out(cx.listener(|this, _, cx| this.dismiss(&Dismiss, cx))) + .max_h(px(550.)) + .min_w(px(230.)) + .p_1p5() + .max_w_128() + .gap_y_0p5() + .children(self.menu_items.iter_mut().enumerate().map(|(ix, item)| { + let this = ListItem::new(("menu-item", ix)) + .on_click(cx.listener(move |this, _, cx| this.on_click(ix, cx))); + match item { + PopupMenuItem::Separator => this + .disabled(true) + .child(div().h(px(1.)).m_1().border_0().bg(cx.theme().border)), + PopupMenuItem::Item { icon, label, .. } => { + this.py_1().px_2().text_sm().rounded(px(4.)).child( + h_flex() + .size_full() + .gap_2() + .items_center() + .children(icon.clone()) + .child(label.clone()), + ) + } + } + })) + } +}