diff --git a/README.md b/README.md index 4481cea9..c3b3f791 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,8 @@ This is an example of build app by using GPUI. - [x] Tabs - [x] Tab - [x] TabBar +- [x] Notification +- [x] Tooltip - [ ] Dockpanel & Splitter - [ ] List - [ ] Table diff --git a/crates/ui-story/src/button_story.rs b/crates/ui-story/src/button_story.rs index b544d14a..64a6afc9 100644 --- a/crates/ui-story/src/button_story.rs +++ b/crates/ui-story/src/button_story.rs @@ -1,5 +1,5 @@ use gpui::{ - div, px, ClickEvent, IntoElement, ParentElement as _, Render, Styled as _, ViewContext, + px, ClickEvent, IntoElement, ParentElement as _, Render, Styled as _, ViewContext, WindowContext, }; diff --git a/crates/ui-story/src/lib.rs b/crates/ui-story/src/lib.rs index 30ab20ab..2e322337 100644 --- a/crates/ui-story/src/lib.rs +++ b/crates/ui-story/src/lib.rs @@ -4,6 +4,7 @@ mod dropdown_story; mod input_story; mod picker_story; mod switch_story; +mod tooltip_stroy; use gpui::{ div, prelude::FluentBuilder as _, px, AnyElement, IntoElement, ParentElement, Render, @@ -25,6 +26,7 @@ use dropdown_story::DropdownStory; use input_story::InputStory; use picker_story::PickerStory; use switch_story::SwitchStory; +use tooltip_stroy::TooltipStory; pub fn story_case(name: &'static str, description: &'static str) -> StoryContainer { StoryContainer::new(name, description) @@ -80,6 +82,7 @@ enum StoryType { Switch, Picker, Dropdown, + Tooltip, } impl Display for StoryType { @@ -91,6 +94,7 @@ impl Display for StoryType { Self::Switch => write!(f, "Switch"), Self::Picker => write!(f, "Picker"), Self::Dropdown => write!(f, "Dropdown"), + Self::Tooltip => write!(f, "Tooltip"), } } } @@ -104,18 +108,20 @@ pub struct Stories { switch_story: View, picker_story: View, dropdown_story: View, + tooltip_story: View, } impl Stories { fn new(cx: &mut ViewContext) -> Self { Self { - active: StoryType::Button, + active: StoryType::Tooltip, button_story: cx.new_view(|_| ButtonStory {}), checkbox_story: cx.new_view(|cx| CheckboxStory::new(cx)), input_story: cx.new_view(|cx| InputStory::new(cx)), switch_story: cx.new_view(|cx| SwitchStory::new(cx)), picker_story: cx.new_view(|cx| PickerStory::new(cx)), dropdown_story: cx.new_view(|cx| DropdownStory::new(cx)), + tooltip_story: cx.new_view(|_| TooltipStory), } } @@ -141,6 +147,7 @@ impl Stories { self.tab("story-switch", StoryType::Switch, cx), self.tab("story-picker", StoryType::Picker, cx), self.tab("story-dropdown", StoryType::Dropdown, cx), + self.tab("story-tooltip", StoryType::Tooltip, cx), ])) } @@ -171,6 +178,7 @@ impl Render for Stories { StoryType::Switch => this.child(self.switch_story.clone()), StoryType::Picker => this.child(self.picker_story.clone()), StoryType::Dropdown => this.child(self.dropdown_story.clone()), + StoryType::Tooltip => this.child(self.tooltip_story.clone()), }) } } diff --git a/crates/ui-story/src/tooltip_stroy.rs b/crates/ui-story/src/tooltip_stroy.rs new file mode 100644 index 00000000..fd5e8334 --- /dev/null +++ b/crates/ui-story/src/tooltip_stroy.rs @@ -0,0 +1,67 @@ +use gpui::{ + div, px, CursorStyle, InteractiveElement, ParentElement, Render, StatefulInteractiveElement, + Styled, +}; + +use ui::{ + button::{Button, ButtonStyle}, + checkbox::Checkbox, + h_flex, + label::Label, + tooltip::Tooltip, + v_flex, Selection, +}; + +use crate::story_case; + +pub struct TooltipStory; + +impl Render for TooltipStory { + fn render(&mut self, cx: &mut gpui::ViewContext) -> impl gpui::IntoElement { + let description = "A popup that displays information related to an element when the element receives keyboard focus or the mouse hovers over it."; + + story_case("Tooltip", description).child( + v_flex() + .w(px(360.)) + .gap_5() + .child( + div() + .cursor(CursorStyle::PointingHand) + .child(Button::new("button", "Hover me").style(ButtonStyle::Primary)) + .id("tooltip-1") + .tooltip(|cx| Tooltip::text("This is a Button", cx)), + ) + .child( + div() + .cursor(CursorStyle::PointingHand) + .child( + Button::new("button-meta", "With meta, Hover me") + .style(ButtonStyle::Primary), + ) + .id("tooltip-2") + .tooltip(|cx| { + Tooltip::with_meta("This is a Button", "Click if you want", cx) + }), + ) + .child( + h_flex() + .justify_center() + .cursor(CursorStyle::PointingHand) + .child(Label::new("Hover me")) + .id("tooltip-3") + .tooltip(|cx| Tooltip::text("This is a Label", cx)), + ) + .child( + div() + .cursor(CursorStyle::PointingHand) + .child( + Checkbox::new("check", cx) + .label("Remember me") + .checked(Selection::Selected), + ) + .id("tooltip-4") + .tooltip(|cx| Tooltip::text("Checked!", cx)), + ), + ) + } +} diff --git a/crates/ui/src/button.rs b/crates/ui/src/button.rs index 09fa273c..fbd18bd6 100644 --- a/crates/ui/src/button.rs +++ b/crates/ui/src/button.rs @@ -5,7 +5,6 @@ use gpui::{ }; use crate::{ - label::Label, span, theme::{ActiveTheme, Colorize as _, ThemeMode}, Clickable, Disableable, Selectable, diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index b28dc30c..7fc6bfff 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -22,6 +22,7 @@ pub mod list; pub mod picker; pub mod switch; pub mod tab; +pub mod tooltip; pub use clickable::Clickable; pub use disableable::Disableable; diff --git a/crates/ui/src/notification.rs b/crates/ui/src/notification.rs new file mode 100644 index 00000000..0f59b26b --- /dev/null +++ b/crates/ui/src/notification.rs @@ -0,0 +1,157 @@ +use std::any::TypeId; +use std::borrow::Cow; +use std::sync::Arc; + +use gpui::{ + AnyView, DismissEvent, ElementId, Entity, EntityId, EventEmitter, IntoElement, ParentElement, + Render, SharedString, Styled, View, ViewContext, WindowContext, +}; + +use crate::theme::ActiveTheme; +use crate::{label::Label, v_flex, StyledExt}; + +#[derive(Debug, PartialEq, Clone)] +pub struct NotificationId { + /// A [`TypeId`] used to uniquely identify this notification. + type_id: TypeId, + /// A supplementary ID used to distinguish between multiple + /// notifications that have the same [`type_id`](Self::type_id); + id: Option, +} + +impl NotificationId { + /// Returns a unique [`NotificationId`] for the given type. + pub fn unique() -> Self { + Self { + type_id: TypeId::of::(), + id: None, + } + } + + /// Returns a [`NotificationId`] for the given type that is also identified + /// by the provided ID. + pub fn identified(id: impl Into) -> Self { + Self { + type_id: TypeId::of::(), + id: Some(id.into()), + } + } +} + +// impl Workspace { +// pub fn dismiss_notification(&mut self, id: &NotificationId, cx: &mut ViewContext) { +// self.dismiss_notification_internal(id, cx) +// } + +// pub fn show_toast(&mut self, toast: MessageNotification, cx: &mut ViewContext) { +// self.dismiss_notification(&toast.id, cx); +// self.show_notification(toast.id, cx, |cx| { +// cx.new_view(|_cx| match toast.on_click.as_ref() { +// Some((click_msg, on_click)) => { +// let on_click = on_click.clone(); +// simple_message_notification::MessageNotification::new(toast.msg.clone()) +// .with_click_message(click_msg.clone()) +// .on_click(move |cx| on_click(cx)) +// } +// None => simple_message_notification::MessageNotification::new(toast.msg.clone()), +// }) +// }) +// } +// } + +pub struct Toast { + id: NotificationId, + msg: Cow<'static, str>, + on_click: Option<(Cow<'static, str>, Arc)>, +} + +impl Toast { + pub fn new>>(id: NotificationId, msg: I) -> Self { + Toast { + id, + msg: msg.into(), + on_click: None, + } + } + + pub fn on_click(mut self, message: M, on_click: F) -> Self + where + M: Into>, + F: Fn(&mut WindowContext) + 'static, + { + self.on_click = Some((message.into(), Arc::new(on_click))); + self + } +} + +impl PartialEq for Toast { + fn eq(&self, other: &Self) -> bool { + self.id == other.id + && self.msg == other.msg + && self.on_click.is_some() == other.on_click.is_some() + } +} + +impl Clone for Toast { + fn clone(&self) -> Self { + Toast { + id: self.id.clone(), + msg: self.msg.clone(), + on_click: self.on_click.clone(), + } + } +} + +pub trait Notification: EventEmitter + Render {} + +impl + Render> Notification for V {} + +pub trait NotificationHandle: Send { + fn id(&self) -> EntityId; + fn to_any(&self) -> AnyView; +} + +impl NotificationHandle for View { + fn id(&self) -> EntityId { + self.entity_id() + } + + fn to_any(&self) -> AnyView { + self.clone().into() + } +} + +impl From<&dyn NotificationHandle> for AnyView { + fn from(val: &dyn NotificationHandle) -> Self { + val.to_any() + } +} + +pub struct MessageNotification { + title: SharedString, + description: SharedString, +} + +impl MessageNotification { + pub fn new(title: impl Into, description: impl Into) -> Self { + Self { + title: title.into(), + description: description.into(), + } + } +} + +impl Render for MessageNotification { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + v_flex() + .elevation_3(cx) + .p_4() + .max_w_80() + .bg(cx.theme().background) + .child( + v_flex() + .child(Label::new(self.title.clone())) + .child(Label::new(self.description.clone())), + ) + } +} diff --git a/crates/ui/src/styled_ext.rs b/crates/ui/src/styled_ext.rs index f6b57fc9..8007c6e6 100644 --- a/crates/ui/src/styled_ext.rs +++ b/crates/ui/src/styled_ext.rs @@ -1,4 +1,4 @@ -use crate::theme::ActiveTheme; +use crate::theme::{ActiveTheme, Colorize}; use gpui::{hsla, point, px, BoxShadow, Styled, WindowContext}; use smallvec::{smallvec, SmallVec}; diff --git a/crates/ui/src/theme.rs b/crates/ui/src/theme.rs index 5bc6c3b6..3e733273 100644 --- a/crates/ui/src/theme.rs +++ b/crates/ui/src/theme.rs @@ -21,6 +21,7 @@ pub trait Colorize { fn opacity(&self, opacity: f32) -> Hsla; fn divide(&self, divisor: f32) -> Hsla; fn invert(&self) -> Hsla; + fn invert_l(&self) -> Hsla; fn lighten(&self, amount: f32) -> Hsla; fn darken(&self, amount: f32) -> Hsla; } @@ -56,6 +57,14 @@ impl Colorize for Hsla { } } + /// Return inverted lightness + fn invert_l(&self) -> Hsla { + Hsla { + l: 1.0 - self.l, + ..*self + } + } + fn lighten(&self, amount: f32) -> Hsla { let l = (self.l + amount).min(1.0); diff --git a/crates/ui/src/tooltip.rs b/crates/ui/src/tooltip.rs new file mode 100644 index 00000000..3559ed18 --- /dev/null +++ b/crates/ui/src/tooltip.rs @@ -0,0 +1,77 @@ +use gpui::{ + div, prelude::FluentBuilder, px, AnyView, Div, IntoElement, ParentElement, Render, + SharedString, Styled, ViewContext, VisualContext, WindowContext, +}; + +use crate::{ + h_flex, + styled_ext::ElevationIndex, + theme::{ActiveTheme, Colorize}, + v_flex, +}; + +pub struct Tooltip { + title: SharedString, + meta: Option, +} + +impl Tooltip { + pub fn text(title: impl Into, cx: &mut WindowContext) -> AnyView { + cx.new_view(|_cx| Self { + title: title.into(), + meta: None, + }) + .into() + } + + pub fn new(title: impl Into) -> Self { + Self { + title: title.into(), + meta: None, + } + } + + pub fn with_meta( + title: impl Into, + meta: impl Into, + cx: &mut WindowContext, + ) -> AnyView { + cx.new_view(|_: &mut ViewContext| Self { + title: title.into(), + meta: Some(meta.into()), + }) + .into() + } + + // TODO: pub fn for_action +} + +impl Render for Tooltip { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + tooltip_container(cx, |el, _| { + el.child(h_flex().gap_4().child(self.title.clone())) + .when_some(self.meta.clone(), |this, meta| { + this.child(div().text_size(px(12.)).child(meta)) + }) + }) + } +} + +pub fn tooltip_container( + cx: &mut ViewContext, + f: impl FnOnce(Div, &mut ViewContext) -> Div, +) -> impl IntoElement { + // padding to avoid tooltip appearing right below the mouse cursor + div().pl_2().pt_2p5().child( + v_flex() + .bg(cx.theme().popover.invert_l()) + .rounded(px(8.)) + .border_1() + .border_color(cx.theme().border.invert_l()) + .shadow(ElevationIndex::ElevatedSurface.shadow()) + .text_color(cx.theme().background) + .py_1() + .px_2() + .map(|el| f(el, cx)), + ) +} diff --git a/crates/workspace/src/lib.rs b/crates/workspace/src/lib.rs index 9d418012..760e748a 100644 --- a/crates/workspace/src/lib.rs +++ b/crates/workspace/src/lib.rs @@ -18,7 +18,6 @@ mod notification; pub use app_state::AppState; pub struct Workspace { - weak_self: WeakView, stories: View, notifications: Vec<(NotificationId, Box)>, } @@ -29,10 +28,7 @@ impl Workspace { _parent: Option>, cx: &mut ViewContext, ) -> Self { - let weak_handle = cx.view().downgrade(); - Workspace { - weak_self: weak_handle.clone(), stories: Stories::view(cx), notifications: Default::default(), }