diff --git a/README.md b/README.md index a41048af..4481cea9 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ This is an example of build app by using GPUI. -https://github.com/huacnlee/gpui-app/assets/5518/ad103f02-697a-40ed-a876-8b13e4242a72 + -https://github.com/huacnlee/gpui-app/assets/5518/5316f9f0-58c8-4b99-bd79-eafffb38c3fc + ## Demo diff --git a/crates/ui-story/src/button_story.rs b/crates/ui-story/src/button_story.rs index 173a7115..b544d14a 100644 --- a/crates/ui-story/src/button_story.rs +++ b/crates/ui-story/src/button_story.rs @@ -13,7 +13,7 @@ use super::story_case; pub struct ButtonStory; impl ButtonStory { - fn on_click(ev: &ClickEvent, cx: &mut WindowContext) { + fn on_click(ev: &ClickEvent, _: &mut WindowContext) { println!("Button clicked! {:?}", ev); } } diff --git a/crates/ui-story/src/lib.rs b/crates/ui-story/src/lib.rs index 3fb2da30..30ab20ab 100644 --- a/crates/ui-story/src/lib.rs +++ b/crates/ui-story/src/lib.rs @@ -1,12 +1,3 @@ -use std::fmt::{self, Display, Formatter}; - -use checkbox_story::CheckboxStory; -use gpui::{ - div, prelude::FluentBuilder as _, px, AnyElement, IntoElement, ParentElement, Render, - RenderOnce, SharedString, StatefulInteractiveElement as _, Styled as _, View, ViewContext, - VisualContext, WindowContext, -}; - mod button_story; mod checkbox_story; mod dropdown_story; @@ -14,6 +5,13 @@ mod input_story; mod picker_story; mod switch_story; +use gpui::{ + div, prelude::FluentBuilder as _, px, AnyElement, IntoElement, ParentElement, Render, + RenderOnce, SharedString, StatefulInteractiveElement as _, Styled as _, View, ViewContext, + VisualContext, WindowContext, +}; + +use std::fmt::{self, Display, Formatter}; use ui::{ divider::Divider, label::Label, @@ -22,6 +20,7 @@ use ui::{ }; use button_story::ButtonStory; +use checkbox_story::CheckboxStory; use dropdown_story::DropdownStory; use input_story::InputStory; use picker_story::PickerStory; @@ -55,7 +54,7 @@ impl StoryContainer { } impl RenderOnce for StoryContainer { - fn render(self, cx: &mut WindowContext) -> impl IntoElement { + fn render(self, _: &mut WindowContext) -> impl IntoElement { div() .flex() .flex_col() diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index 8e72d78c..d2bc1f0b 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -12,4 +12,4 @@ serde_json = "1" wry = "0" smallvec = "1.13.2" windows = "0.57.0" -unicode-segmentation = "1.11.0" \ No newline at end of file +unicode-segmentation = "1.11.0" diff --git a/crates/ui/src/switch.rs b/crates/ui/src/switch.rs index cd687cba..ebae3cfa 100644 --- a/crates/ui/src/switch.rs +++ b/crates/ui/src/switch.rs @@ -1,12 +1,11 @@ use crate::{ button::ButtonSize, - label::Label, stock::h_flex, theme::{ActiveTheme, Colorize}, Disableable, }; use gpui::{ - div, prelude::FluentBuilder as _, px, ClickEvent, Div, InteractiveElement, IntoElement, + div, prelude::FluentBuilder as _, ClickEvent, Div, InteractiveElement, IntoElement, ParentElement as _, RenderOnce, SharedString, Stateful, StatefulInteractiveElement, Styled as _, WindowContext, }; @@ -141,9 +140,8 @@ impl RenderOnce for Switch { .when_some( self.on_click.filter(|_| !self.disabled), |this, on_click| { - this.on_click(move |ev, cx| { - on_click(ev, cx); - }) + cx.stop_propagation(); + this.on_click(move |ev, cx| on_click(ev, cx)) }, ) } diff --git a/crates/workspace/src/lib.rs b/crates/workspace/src/lib.rs index f26114fa..9d418012 100644 --- a/crates/workspace/src/lib.rs +++ b/crates/workspace/src/lib.rs @@ -1,12 +1,10 @@ use gpui::*; +use notification::{NotificationHandle, NotificationId}; use prelude::FluentBuilder as _; use std::sync::Arc; use ui::{ button::ButtonSize, - input, - label::Label, - picker, switch::{LabelSide, Switch}, theme::{ActiveTheme, Theme}, title_bar::TitleBar, @@ -15,12 +13,14 @@ use ui_story::Stories; use util::ResultExt as _; mod app_state; +mod notification; pub use app_state::AppState; pub struct Workspace { weak_self: WeakView, stories: View, + notifications: Vec<(NotificationId, Box)>, } impl Workspace { @@ -34,6 +34,7 @@ impl Workspace { Workspace { weak_self: weak_handle.clone(), stories: Stories::view(cx), + notifications: Default::default(), } } @@ -80,6 +81,29 @@ impl Workspace { Ok(window) }) } + + fn render_notifications(&self, _cx: &ViewContext) -> Option
{ + if self.notifications.is_empty() { + None + } else { + Some( + div() + .absolute() + .right_3() + .bottom_3() + .h_full() + .flex() + .flex_col() + .justify_end() + .gap_2() + .children( + self.notifications + .iter() + .map(|(_, notification)| notification.to_any()), + ), + ) + } + } } actions!(workspace, [Open, CloseWindow]); @@ -110,10 +134,10 @@ impl Render for Workspace { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { div() .relative() + .size_full() .flex() .flex_1() .flex_col() - .size_full() .bg(cx.theme().background) .text_color(cx.theme().foreground) .gap_4() @@ -159,6 +183,7 @@ impl Render for Workspace { ), ), ) + .children(self.render_notifications(cx)) .child(div().flex().px_4().gap_2().child(self.stories.clone())) } } diff --git a/crates/workspace/src/notification.rs b/crates/workspace/src/notification.rs new file mode 100644 index 00000000..96982519 --- /dev/null +++ b/crates/workspace/src/notification.rs @@ -0,0 +1,222 @@ +use std::any::TypeId; +use std::borrow::Cow; +use std::sync::Arc; + +use gpui::{ + div, AnyView, DismissEvent, ElementId, Entity, EntityId, EventEmitter, InteractiveElement, + IntoElement, ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, View, + ViewContext, VisualContext, WindowContext, +}; + +use ui::{h_flex, label::Label, theme::ActiveTheme, v_flex, Icon, IconName, StyledExt}; + +use crate::Workspace; + +#[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 show_notification( + &mut self, + id: NotificationId, + cx: &mut ViewContext, + build_notification: impl FnOnce(&mut ViewContext) -> View, + ) { + self.dismiss_notification_internal(&id, cx); + + let notification = build_notification(cx); + cx.subscribe(¬ification, { + let id = id.clone(); + move |this, _, _: &DismissEvent, cx| { + this.dismiss_notification_internal(&id, cx); + } + }) + .detach(); + self.notifications.push((id, Box::new(notification))); + cx.notify(); + } + + fn dismiss_notification_internal(&mut self, id: &NotificationId, cx: &mut ViewContext) { + self.notifications.retain(|(existing_id, _)| { + if existing_id == id { + cx.notify(); + false + } else { + true + } + }); + } + + pub fn dismiss_notification(&mut self, id: &NotificationId, cx: &mut ViewContext) { + self.dismiss_notification_internal(id, cx) + } + + pub fn show_toast(&mut self, toast: Toast, 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(); + MessageNotification::new(toast.msg.clone()) + .with_click_message(click_msg.clone()) + .on_click(move |cx| on_click(cx)) + } + None => 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 { + message: SharedString, + on_click: Option)>>, + click_message: Option, +} + +impl EventEmitter for MessageNotification {} + +impl MessageNotification { + pub fn new(message: impl Into) -> Self { + Self { + message: message.into(), + on_click: None, + click_message: None, + } + } + + pub fn with_click_message(mut self, message: S) -> Self + where + S: Into, + { + self.click_message = Some(message.into()); + self + } + + pub fn on_click(mut self, on_click: F) -> Self + where + F: 'static + Fn(&mut ViewContext), + { + self.on_click = Some(Arc::new(on_click)); + self + } + + pub fn dismiss(&mut self, cx: &mut ViewContext) { + cx.emit(DismissEvent); + } +} + +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( + h_flex() + .justify_between() + .gap_2() + .child(div().max_w_80().child(Label::new(self.message.clone()))) + .child( + div() + .id("cancel") + .child(Icon::new(IconName::Close)) + .cursor_pointer() + .on_click(cx.listener(|this, _, cx| this.dismiss(cx))), + ), + ) + } +}