From c9763224c27337c7babdffcdddd876af672bfe22 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Thu, 15 Aug 2024 16:50:33 +0800 Subject: [PATCH] Add Notification (#149) https://github.com/user-attachments/assets/8a10bff7-f669-471e-8516-2a64ae95ae0a --- Cargo.lock | 1 + README.md | 4 +- assets/icons/circle-check.svg | 1 + assets/icons/triangle-alert.svg | 1 + crates/app/src/story_workspace.rs | 2 + crates/story/src/modal_story.rs | 64 ++++++ crates/ui/Cargo.toml | 1 + crates/ui/src/icon.rs | 4 + crates/ui/src/lib.rs | 1 + crates/ui/src/notification.rs | 311 ++++++++++++++++++++++++++++++ crates/ui/src/root.rs | 55 +++++- 11 files changed, 440 insertions(+), 5 deletions(-) create mode 100644 assets/icons/circle-check.svg create mode 100644 assets/icons/triangle-alert.svg create mode 100644 crates/ui/src/notification.rs diff --git a/Cargo.lock b/Cargo.lock index fec44182..ed74561c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5503,6 +5503,7 @@ dependencies = [ "smol", "unicode-segmentation", "usvg", + "uuid", "wry", ] diff --git a/README.md b/README.md index 99e932cf..82fdb9e2 100644 --- a/README.md +++ b/README.md @@ -69,8 +69,8 @@ A UI components for building desktop application by using [GPUI](https://gpui.rs - [x] Context Menu - [x] Drawer - [x] Modal -- [ ] Notification -- [ ] Toast +- [x] Notification + - [ ] Collapsible Notifications ## Showcase diff --git a/assets/icons/circle-check.svg b/assets/icons/circle-check.svg new file mode 100644 index 00000000..4a7e1014 --- /dev/null +++ b/assets/icons/circle-check.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/triangle-alert.svg b/assets/icons/triangle-alert.svg new file mode 100644 index 00000000..1861c2e9 --- /dev/null +++ b/assets/icons/triangle-alert.svg @@ -0,0 +1 @@ + diff --git a/crates/app/src/story_workspace.rs b/crates/app/src/story_workspace.rs index e5c5a972..a6df1d5a 100644 --- a/crates/app/src/story_workspace.rs +++ b/crates/app/src/story_workspace.rs @@ -276,6 +276,7 @@ impl Render for StoryWorkspace { let active_modal = cx.active_modal(); let active_drawer = cx.active_drawer(); let has_active_modal = active_modal.is_some(); + let notification_view = cx.notification_view(); div() .relative() @@ -347,6 +348,7 @@ impl Render for StoryWorkspace { let modal = Modal::new(cx); this.child(builder(modal, cx)) }) + .child(div().absolute().top_8().child(notification_view)) } } diff --git a/crates/story/src/modal_story.rs b/crates/story/src/modal_story.rs index d957ea35..280e70a6 100644 --- a/crates/story/src/modal_story.rs +++ b/crates/story/src/modal_story.rs @@ -14,6 +14,7 @@ use ui::{ h_flex, input::TextInput, list::{List, ListDelegate, ListItem}, + notification::{Notification, NotificationType}, theme::ActiveTheme as _, v_flex, ContextModal as _, Icon, IconName, Placement, }; @@ -310,6 +311,7 @@ impl ModalStory { input1.focus_handle(cx).focus(cx); modal + .margin_top(px(33.)) .title("Form Modal") .overlay(overlay) .show_close(modal_show_close) @@ -448,6 +450,68 @@ impl Render for ModalStory { Button::new("show-modal", cx) .label("Open Modal...") .on_click(cx.listener(|this, _, cx| this.show_modal(cx))), + ) + .child( + h_flex() + .gap_3() + .child( + Button::new("show-notify-info", cx) + .label("Info Notify...") + .on_click(cx.listener(|_, _, cx| { + cx.push_notification("You have been saved file successfully.") + })), + ) + .child( + Button::new("show-notify-error", cx) + .label("Error Notify...") + .on_click(cx.listener(|_, _, cx| { + cx.push_notification(( + NotificationType::Error, + "There have some error occurred. Please try again later.", + )) + })), + ) + .child( + Button::new("show-notify-success", cx) + .label("Success Notify...") + .on_click(cx.listener(|_, _, cx| { + cx.push_notification(( + NotificationType::Success, + "We have received your payment successfully.", + )) + })), + ) + .child( + Button::new("show-notify-warning", cx) + .label("Warning Notify...") + .on_click(cx.listener(|_, _, cx| { + cx.push_notification(( + NotificationType::Warning, + "The network is not stable, please check your connection.", + )) + })), + ) + .child( + Button::new("show-notify-warning", cx) + .label("Notification with Title") + .on_click(cx.listener(|_, _, cx| { + cx.push_notification( + Notification::new( + "你已经成功保存了文件,但是有一些警告信息需要你注意。", + ) + .title("保存成功") + .icon(IconName::Inbox) + .autohide(false) + .on_click( + cx.listener(|view, _, cx| { + view.selected_value = + Some("Notification clicked".into()); + cx.notify(); + }), + ), + ) + })), + ), ), ) } diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index d14d9dcd..f5db24fd 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -25,6 +25,7 @@ wry = "0" smol = "1" regex = "1" rust-i18n = "3" +uuid = "1.10" # Calendar chrono = "0.4.38" diff --git a/crates/ui/src/icon.rs b/crates/ui/src/icon.rs index 0888aae1..63b49e69 100644 --- a/crates/ui/src/icon.rs +++ b/crates/ui/src/icon.rs @@ -49,6 +49,8 @@ pub enum IconName { Sun, ThumbsDown, ThumbsUp, + TriangleAlert, + CircleCheck, } impl IconName { @@ -97,6 +99,8 @@ impl IconName { IconName::Sun => "icons/sun.svg", IconName::ThumbsDown => "icons/thumbs-down.svg", IconName::ThumbsUp => "icons/thumbs-up.svg", + IconName::TriangleAlert => "icons/triangle-alert.svg", + IconName::CircleCheck => "icons/circle-check.svg", } .into() } diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 46223e30..453a6a63 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -21,6 +21,7 @@ pub mod label; pub mod link; pub mod list; pub mod modal; +pub mod notification; pub mod popover; pub mod popup_menu; pub mod prelude; diff --git a/crates/ui/src/notification.rs b/crates/ui/src/notification.rs new file mode 100644 index 00000000..397d293d --- /dev/null +++ b/crates/ui/src/notification.rs @@ -0,0 +1,311 @@ +use std::{sync::Arc, time::Duration}; + +use gpui::{ + div, prelude::FluentBuilder as _, px, Animation, AnimationExt, ClickEvent, DismissEvent, + ElementId, EventEmitter, InteractiveElement as _, IntoElement, ParentElement as _, Render, + SharedString, StatefulInteractiveElement, Styled, View, ViewContext, VisualContext, + WindowContext, +}; +use smol::Timer; + +use crate::{ + button::Button, h_flex, theme::ActiveTheme as _, v_flex, Icon, IconName, Sizable as _, + StyledExt, +}; + +pub enum NotificationType { + Info, + Success, + Warning, + Error, +} + +pub struct Notification { + /// The id is used make the notification unique. + /// Then you push a notification with the same id, the previous notification will be replaced. + /// + /// None means the notification will be added to the end of the list. + id: ElementId, + type_: NotificationType, + title: Option, + content: SharedString, + icon: Option, + autohide: bool, + on_click: Option>, +} + +impl From for Notification { + fn from(s: SharedString) -> Self { + Self::new(s) + } +} + +impl From<&'static str> for Notification { + fn from(s: &'static str) -> Self { + Self::new(s) + } +} + +impl From<(NotificationType, &'static str)> for Notification { + fn from((type_, content): (NotificationType, &'static str)) -> Self { + Self::new(content).with_type(type_) + } +} + +impl From<(NotificationType, SharedString)> for Notification { + fn from((type_, content): (NotificationType, SharedString)) -> Self { + Self::new(content).with_type(type_) + } +} + +impl Notification { + /// Create a new notification with the given content. + /// + /// default width is 320px. + pub fn new(content: impl Into) -> Self { + let id = uuid::Uuid::new_v4().to_string(); + + Self { + id: SharedString::from(id).into(), + title: None, + content: content.into(), + type_: NotificationType::Info, + icon: None, + autohide: true, + on_click: None, + } + } + + pub fn with_id(mut self, id: impl Into) -> Self { + self.id = id.into(); + self + } + + /// Set the title of the notification, default is None. + /// + /// If tilte is None, the notification will not have a title. + pub fn title(mut self, title: impl Into) -> Self { + self.title = Some(title.into()); + self + } + + /// Set the icon of the notification. + /// + /// If icon is None, the notification will use the default icon of the type. + pub fn icon(mut self, icon: impl Into) -> Self { + self.icon = Some(icon.into()); + self + } + + pub fn with_type(mut self, type_: NotificationType) -> Self { + self.type_ = type_; + self + } + + pub fn info(mut self) -> Self { + self.type_ = NotificationType::Info; + self + } + + pub fn success(mut self) -> Self { + self.type_ = NotificationType::Success; + self + } + + pub fn warning(mut self) -> Self { + self.type_ = NotificationType::Warning; + self + } + + pub fn error(mut self) -> Self { + self.type_ = NotificationType::Error; + self + } + + /// Set the auto hide of the notification, default is true. + pub fn autohide(mut self, autohide: bool) -> Self { + self.autohide = autohide; + self + } + + /// Set the click callback of the notification. + pub fn on_click( + mut self, + on_click: impl Fn(&ClickEvent, &mut WindowContext) + 'static, + ) -> Self { + self.on_click = Some(Arc::new(on_click)); + self + } + + fn perform_autohide(&self, cx: &mut ViewContext) { + if !self.autohide { + return; + } + + // Sleep for 5 seconds to autohide the notification + cx.spawn(|view, mut cx| async move { + Timer::after(Duration::from_secs(5)).await; + let _ = view.update(&mut cx, |_, cx| cx.emit(DismissEvent)); + }) + .detach(); + } + + fn dismiss(&mut self, _: &ClickEvent, cx: &mut ViewContext) { + cx.emit(DismissEvent); + } +} +impl EventEmitter for Notification {} + +impl Render for Notification { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + let group_id = "notification-group"; + self.perform_autohide(cx); + + let icon = match self.icon.clone() { + Some(icon) => icon, + None => match self.type_ { + NotificationType::Info => Icon::new(IconName::Info).text_color(crate::blue_500()), + NotificationType::Success => { + Icon::new(IconName::CircleCheck).text_color(crate::green_500()) + } + NotificationType::Warning => { + Icon::new(IconName::TriangleAlert).text_color(crate::yellow_500()) + } + NotificationType::Error => { + Icon::new(IconName::CircleX).text_color(crate::red_500()) + } + }, + }; + + div() + .w_96() + .id("notification") + .occlude() + .group(group_id) + .relative() + .border_1() + .border_color(cx.theme().border) + .bg(cx.theme().popover) + .rounded_md() + .shadow_md() + .py_2() + .px_4() + .gap_3() + .child( + div() + .absolute() + .map(|this| match self.title.is_some() { + true => this.top_3().left_4(), + false => this.top_2p5().left_4(), + }) + .child(icon), + ) + .child( + v_flex() + .pl_6() + .gap_1() + .when_some(self.title.clone(), |this, title| { + this.child(div().text_sm().font_semibold().child(title)) + }) + .overflow_hidden() + .child(div().text_sm().child(self.content.clone())), + ) + .when_some(self.on_click.clone(), |this, on_click| { + this.cursor_pointer() + .on_click(cx.listener(move |_, event, cx| { + cx.emit(DismissEvent); + on_click(event, cx); + })) + }) + .when(!self.autohide, |this| { + this.child( + h_flex() + .absolute() + .top_1() + .right_1() + .invisible() + .group_hover(group_id, |this| this.visible()) + .child( + Button::new("close", cx) + .icon(IconName::Close) + .ghost() + .xsmall() + .on_click(cx.listener(Self::dismiss)), + ), + ) + }) + .with_animation( + "slide-left", + Animation::new(Duration::from_secs_f64(0.1)), + move |this, delta| { + let x_offset = px(120.) + delta * px(-120.); + this.left(px(0.) + x_offset) + }, + ) + } +} + +/// A list of notifications. +pub struct NotificationList { + notifications: Vec>, +} + +impl NotificationList { + pub fn new(_cx: &mut ViewContext) -> Self { + Self { + notifications: Vec::new(), + } + } + + pub fn push(&mut self, notification: impl Into, cx: &mut ViewContext) { + let notification = notification.into(); + let id = notification.id.clone(); + + // Remove the notification by id, for keep unique. + self.notifications.retain(|note| note.read(cx).id != id); + + let notification = cx.new_view(|_| notification); + cx.subscribe(¬ification, move |view, _, _: &DismissEvent, cx| { + view.notifications.retain(|note| id != note.read(cx).id); + }) + .detach(); + + self.notifications.push(notification); + cx.notify(); + } + + pub fn clear(&mut self, cx: &mut ViewContext) { + self.notifications.clear(); + cx.notify(); + } +} + +impl Render for NotificationList { + fn render(&mut self, cx: &mut gpui::ViewContext) -> impl IntoElement { + let size = cx.viewport_size(); + + let last_10_notes = self + .notifications + .iter() + .rev() + .take(10) + .rev() + .cloned() + .collect::>(); + + div() + .absolute() + .top_4() + .bottom_4() + .right_4() + .justify_end() + .child( + v_flex() + .absolute() + .right_0() + .h(size.height) + .gap_3() + .children(last_10_notes), + ) + } +} diff --git a/crates/ui/src/root.rs b/crates/ui/src/root.rs index 52f900b7..cc9f7c95 100644 --- a/crates/ui/src/root.rs +++ b/crates/ui/src/root.rs @@ -1,12 +1,18 @@ use gpui::{ - div, AnyView, FocusHandle, ParentElement as _, Render, Styled, ViewContext, WindowContext, + div, AnyView, FocusHandle, ParentElement as _, Render, Styled, View, ViewContext, + VisualContext as _, WindowContext, }; use std::{ ops::{Deref, DerefMut}, rc::Rc, }; -use crate::{drawer::Drawer, modal::Modal, theme::ActiveTheme}; +use crate::{ + drawer::Drawer, + modal::Modal, + notification::{Notification, NotificationList}, + theme::ActiveTheme, +}; /// Extension trait for [`WindowContext`] and [`ViewContext`] to add drawer functionality. pub trait ContextModal: Sized { @@ -31,6 +37,12 @@ pub trait ContextModal: Sized { /// Closes the active Modal. fn close_modal(&mut self); + + /// Pushes a notification to the notification list. + fn push_notification(&mut self, note: impl Into); + fn clear_notifications(&mut self); + /// Returns the notification list view. + fn notification_view(&self) -> AnyView; } impl<'a> ContextModal for WindowContext<'a> { @@ -79,6 +91,26 @@ impl<'a> ContextModal for WindowContext<'a> { cx.notify(); }) } + + fn push_notification(&mut self, note: impl Into) { + let note = note.into(); + Root::update_root(self, move |root, cx| { + root.notification_list + .update(cx, |view, cx| view.push(note, cx)); + cx.notify(); + }) + } + + fn clear_notifications(&mut self) { + Root::update_root(self, move |root, cx| { + root.notification_list.update(cx, |view, cx| view.clear(cx)); + cx.notify(); + }) + } + + fn notification_view(&self) -> AnyView { + Root::read_root(&self).notification_list.clone().into() + } } impl<'a, V> ContextModal for ViewContext<'a, V> { fn open_drawer(&mut self, build: F) @@ -110,23 +142,40 @@ impl<'a, V> ContextModal for ViewContext<'a, V> { fn close_modal(&mut self) { self.deref_mut().close_modal() } + + fn push_notification(&mut self, note: impl Into) { + self.deref_mut().push_notification(note) + } + + fn clear_notifications(&mut self) { + self.deref_mut().clear_notifications() + } + + fn notification_view(&self) -> AnyView { + self.deref().notification_view() + } } +/// Root is a view for the App window for as the top level view (Must be the first view in the window). +/// +/// It is used to manage the Drawer, Modal, and Notification. pub struct Root { /// Used to store the focus handle of the previus revious view. /// When the Modal, Drawer closes, we will focus back to the previous view. previous_focus_handle: Option, active_drawer: Option Drawer + 'static>>, active_modal: Option Modal + 'static>>, + notification_list: View, child: AnyView, } impl Root { - pub fn new(child: AnyView, _: &mut ViewContext) -> Self { + pub fn new(child: AnyView, cx: &mut ViewContext) -> Self { Self { previous_focus_handle: None, active_drawer: None, active_modal: None, + notification_list: cx.new_view(NotificationList::new), child, } }