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,
}
}