Add Notification (#149)
https://github.com/user-attachments/assets/8a10bff7-f669-471e-8516-2a64ae95ae0a
This commit is contained in:
parent
6d86e64846
commit
c9763224c2
11 changed files with 440 additions and 5 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -5503,6 +5503,7 @@ dependencies = [
|
|||
"smol",
|
||||
"unicode-segmentation",
|
||||
"usvg",
|
||||
"uuid",
|
||||
"wry",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
1
assets/icons/circle-check.svg
Normal file
1
assets/icons/circle-check.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-check"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>
|
||||
|
After Width: | Height: | Size: 280 B |
1
assets/icons/triangle-alert.svg
Normal file
1
assets/icons/triangle-alert.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-triangle-alert"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>
|
||||
|
After Width: | Height: | Size: 350 B |
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}),
|
||||
),
|
||||
)
|
||||
})),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ wry = "0"
|
|||
smol = "1"
|
||||
regex = "1"
|
||||
rust-i18n = "3"
|
||||
uuid = "1.10"
|
||||
|
||||
# Calendar
|
||||
chrono = "0.4.38"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
311
crates/ui/src/notification.rs
Normal file
311
crates/ui/src/notification.rs
Normal file
|
|
@ -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<SharedString>,
|
||||
content: SharedString,
|
||||
icon: Option<Icon>,
|
||||
autohide: bool,
|
||||
on_click: Option<Arc<dyn Fn(&ClickEvent, &mut WindowContext)>>,
|
||||
}
|
||||
|
||||
impl From<SharedString> 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<SharedString>) -> 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<ElementId>) -> 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<SharedString>) -> 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<Icon>) -> 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<Self>) {
|
||||
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<Self>) {
|
||||
cx.emit(DismissEvent);
|
||||
}
|
||||
}
|
||||
impl EventEmitter<DismissEvent> for Notification {}
|
||||
|
||||
impl Render for Notification {
|
||||
fn render(&mut self, cx: &mut ViewContext<Self>) -> 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<View<Notification>>,
|
||||
}
|
||||
|
||||
impl NotificationList {
|
||||
pub fn new(_cx: &mut ViewContext<Self>) -> Self {
|
||||
Self {
|
||||
notifications: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push(&mut self, notification: impl Into<Notification>, cx: &mut ViewContext<Self>) {
|
||||
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>) {
|
||||
self.notifications.clear();
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for NotificationList {
|
||||
fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> impl IntoElement {
|
||||
let size = cx.viewport_size();
|
||||
|
||||
let last_10_notes = self
|
||||
.notifications
|
||||
.iter()
|
||||
.rev()
|
||||
.take(10)
|
||||
.rev()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Notification>);
|
||||
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<Notification>) {
|
||||
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<F>(&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<Notification>) {
|
||||
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<FocusHandle>,
|
||||
active_drawer: Option<Rc<dyn Fn(Drawer, &mut WindowContext) -> Drawer + 'static>>,
|
||||
active_modal: Option<Rc<dyn Fn(Modal, &mut WindowContext) -> Modal + 'static>>,
|
||||
notification_list: View<NotificationList>,
|
||||
child: AnyView,
|
||||
}
|
||||
|
||||
impl Root {
|
||||
pub fn new(child: AnyView, _: &mut ViewContext<Self>) -> Self {
|
||||
pub fn new(child: AnyView, cx: &mut ViewContext<Self>) -> Self {
|
||||
Self {
|
||||
previous_focus_handle: None,
|
||||
active_drawer: None,
|
||||
active_modal: None,
|
||||
notification_list: cx.new_view(NotificationList::new),
|
||||
child,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue