Checkpoint – Notifications Panel

Nate Butler created

Change summary

crates/ui2/src/components.rs                     |   6 
crates/ui2/src/components/list.rs                | 109 +++++++++++++++++
crates/ui2/src/components/notification.rs        |  90 --------------
crates/ui2/src/components/notification_toast.rs  |  48 +++++++
crates/ui2/src/components/notifications_panel.rs |  80 +++++++++++++
crates/ui2/src/components/workspace.rs           |   7 
crates/ui2/src/elements/button.rs                |  25 ++++
crates/ui2/src/elements/details.rs               |  14 +
crates/ui2/src/prelude.rs                        |   8 +
crates/ui2/src/static_data.rs                    |  38 +++++
10 files changed, 318 insertions(+), 107 deletions(-)

Detailed changes

crates/ui2/src/components.rs 🔗

@@ -13,7 +13,8 @@ mod keybinding;
 mod language_selector;
 mod list;
 mod multi_buffer;
-mod notification;
+mod notification_toast;
+mod notifications_panel;
 mod palette;
 mod panel;
 mod panes;
@@ -46,7 +47,8 @@ pub use keybinding::*;
 pub use language_selector::*;
 pub use list::*;
 pub use multi_buffer::*;
-pub use notification::*;
+pub use notification_toast::*;
+pub use notifications_panel::*;
 pub use palette::*;
 pub use panel::*;
 pub use panes::*;

crates/ui2/src/components/list.rs 🔗

@@ -1,10 +1,13 @@
 use std::marker::PhantomData;
 
-use gpui3::{div, Div};
+use gpui3::{div, relative, Div};
 
-use crate::prelude::*;
 use crate::settings::user_settings;
-use crate::{h_stack, v_stack, Avatar, Icon, IconColor, IconElement, IconSize, Label, LabelColor};
+use crate::{
+    h_stack, v_stack, Avatar, ClickHandler, Icon, IconColor, IconElement, IconSize, Label,
+    LabelColor,
+};
+use crate::{prelude::*, Button};
 
 #[derive(Clone, Copy, Default, Debug, PartialEq)]
 pub enum ListItemVariant {
@@ -201,6 +204,7 @@ pub enum ListEntrySize {
 #[derive(Element)]
 pub enum ListItem<S: 'static + Send + Sync> {
     Entry(ListEntry<S>),
+    Details(ListDetailsEntry<S>),
     Separator(ListSeparator<S>),
     Header(ListSubHeader<S>),
 }
@@ -211,6 +215,12 @@ impl<S: 'static + Send + Sync> From<ListEntry<S>> for ListItem<S> {
     }
 }
 
+impl<S: 'static + Send + Sync> From<ListDetailsEntry<S>> for ListItem<S> {
+    fn from(entry: ListDetailsEntry<S>) -> Self {
+        Self::Details(entry)
+    }
+}
+
 impl<S: 'static + Send + Sync> From<ListSeparator<S>> for ListItem<S> {
     fn from(entry: ListSeparator<S>) -> Self {
         Self::Separator(entry)
@@ -229,6 +239,7 @@ impl<S: 'static + Send + Sync> ListItem<S> {
             ListItem::Entry(entry) => div().child(entry.render(view, cx)),
             ListItem::Separator(separator) => div().child(separator.render(view, cx)),
             ListItem::Header(header) => div().child(header.render(view, cx)),
+            ListItem::Details(details) => div().child(details.render(view, cx)),
         }
     }
 
@@ -255,6 +266,7 @@ pub struct ListEntry<S: 'static + Send + Sync> {
     size: ListEntrySize,
     state: InteractionState,
     toggle: Option<ToggleState>,
+    overflow: OverflowStyle,
 }
 
 impl<S: 'static + Send + Sync> ListEntry<S> {
@@ -270,6 +282,7 @@ impl<S: 'static + Send + Sync> ListEntry<S> {
             // TODO: Should use Toggleable::NotToggleable
             // or remove Toggleable::NotToggleable from the system
             toggle: None,
+            overflow: OverflowStyle::Hidden,
         }
     }
     pub fn set_variant(mut self, variant: ListItemVariant) -> Self {
@@ -416,6 +429,96 @@ impl<S: 'static + Send + Sync> ListEntry<S> {
     }
 }
 
+struct ListDetailsEntryHandlers<S: 'static + Send + Sync> {
+    click: Option<ClickHandler<S>>,
+}
+
+impl<S: 'static + Send + Sync> Default for ListDetailsEntryHandlers<S> {
+    fn default() -> Self {
+        Self { click: None }
+    }
+}
+
+#[derive(Element)]
+pub struct ListDetailsEntry<S: 'static + Send + Sync> {
+    label: SharedString,
+    meta: Option<SharedString>,
+    left_content: Option<LeftContent>,
+    handlers: ListDetailsEntryHandlers<S>,
+    actions: Option<Vec<Button<S>>>,
+    // TODO: make this more generic instead of
+    // specifically for notifications
+    seen: bool,
+}
+
+impl<S: 'static + Send + Sync> ListDetailsEntry<S> {
+    pub fn new(label: impl Into<SharedString>) -> Self {
+        Self {
+            label: label.into(),
+            meta: None,
+            left_content: None,
+            handlers: ListDetailsEntryHandlers::default(),
+            actions: None,
+            seen: false,
+        }
+    }
+
+    pub fn meta(mut self, meta: impl Into<SharedString>) -> Self {
+        self.meta = Some(meta.into());
+        self
+    }
+
+    pub fn seen(mut self, seen: bool) -> Self {
+        self.seen = seen;
+        self
+    }
+
+    pub fn on_click(mut self, handler: ClickHandler<S>) -> Self {
+        self.handlers.click = Some(handler);
+        self
+    }
+
+    pub fn actions(mut self, actions: Vec<Button<S>>) -> Self {
+        self.actions = Some(actions);
+        self
+    }
+
+    fn render(&mut self, _view: &mut S, cx: &mut ViewContext<S>) -> impl Element<ViewState = S> {
+        let color = ThemeColor::new(cx);
+        let settings = user_settings(cx);
+
+        let (item_bg, item_bg_hover, item_bg_active) = match self.seen {
+            true => (
+                color.ghost_element,
+                color.ghost_element_hover,
+                color.ghost_element_active,
+            ),
+            false => (
+                color.filled_element,
+                color.filled_element_hover,
+                color.filled_element_active,
+            ),
+        };
+
+        let label_color = match self.seen {
+            true => LabelColor::Muted,
+            false => LabelColor::Default,
+        };
+
+        v_stack()
+            .relative()
+            .group("")
+            .bg(item_bg)
+            .p_1()
+            .w_full()
+            .line_height(relative(1.2))
+            .child(Label::new(self.label.clone()).color(label_color))
+            .when(self.meta.is_some(), |this| {
+                this.child(Label::new(self.meta.clone().unwrap()).color(LabelColor::Muted))
+            })
+    }
+}
+
 #[derive(Clone, Element)]
 pub struct ListSeparator<S: 'static + Send + Sync> {
     state_type: PhantomData<S>,

crates/ui2/src/components/notification.rs 🔗

@@ -1,90 +0,0 @@
-use std::marker::PhantomData;
-
-use gpui3::{Element, ParentElement, Styled, ViewContext};
-
-use crate::{
-    h_stack, v_stack, Button, Icon, IconButton, IconElement, Label, ThemeColor, Toast, ToastOrigin,
-};
-
-/// Notification toasts are used to display a message
-/// that requires them to take action.
-///
-/// You must provide a primary action for the user to take.
-///
-/// To simply convey information, use a `StatusToast`.
-#[derive(Element)]
-pub struct NotificationToast<S: 'static + Send + Sync + Clone> {
-    state_type: PhantomData<S>,
-    left_icon: Option<Icon>,
-    title: String,
-    message: String,
-    primary_action: Option<Button<S>>,
-    secondary_action: Option<Button<S>>,
-}
-
-impl<S: 'static + Send + Sync + Clone> NotificationToast<S> {
-    pub fn new(
-        // TODO: use a `SharedString` here
-        title: impl Into<String>,
-        message: impl Into<String>,
-        primary_action: Button<S>,
-    ) -> Self {
-        Self {
-            state_type: PhantomData,
-            left_icon: None,
-            title: title.into(),
-            message: message.into(),
-            primary_action: Some(primary_action),
-            secondary_action: None,
-        }
-    }
-
-    pub fn left_icon(mut self, icon: Icon) -> Self {
-        self.left_icon = Some(icon);
-        self
-    }
-
-    pub fn secondary_action(mut self, action: Button<S>) -> Self {
-        self.secondary_action = Some(action);
-        self
-    }
-
-    fn render(&mut self, _view: &mut S, cx: &mut ViewContext<S>) -> impl Element<ViewState = S> {
-        let color = ThemeColor::new(cx);
-
-        let notification = h_stack()
-            .min_w_64()
-            .max_w_96()
-            .gap_1()
-            .items_start()
-            .p_1()
-            .children(self.left_icon.map(|i| IconElement::new(i)))
-            .child(
-                v_stack()
-                    .flex_1()
-                    .w_full()
-                    .gap_1()
-                    .child(
-                        h_stack()
-                            .justify_between()
-                            .child(Label::new(self.title.clone()))
-                            .child(IconButton::new(Icon::Close).color(crate::IconColor::Muted)),
-                    )
-                    .child(
-                        v_stack()
-                            .overflow_hidden_x()
-                            .gap_1()
-                            .child(Label::new(self.message.clone()))
-                            .child(
-                                h_stack()
-                                    .gap_1()
-                                    .justify_end()
-                                    .children(self.secondary_action.take())
-                                    .children(self.primary_action.take()),
-                            ),
-                    ),
-            );
-
-        Toast::new(ToastOrigin::BottomRight).child(notification)
-    }
-}

crates/ui2/src/components/notification_toast.rs 🔗

@@ -0,0 +1,48 @@
+use std::marker::PhantomData;
+
+use gpui3::rems;
+
+use crate::{h_stack, prelude::*, Icon};
+
+#[derive(Element)]
+pub struct NotificationToast<S: 'static + Send + Sync + Clone> {
+    state_type: PhantomData<S>,
+    label: SharedString,
+    icon: Option<Icon>,
+}
+
+impl<S: 'static + Send + Sync + Clone> NotificationToast<S> {
+    pub fn new(label: SharedString) -> Self {
+        Self {
+            state_type: PhantomData,
+            label,
+            icon: None,
+        }
+    }
+
+    pub fn icon<I>(mut self, icon: I) -> Self
+    where
+        I: Into<Option<Icon>>,
+    {
+        self.icon = icon.into();
+        self
+    }
+
+    fn render(&mut self, _view: &mut S, cx: &mut ViewContext<S>) -> impl Element<ViewState = S> {
+        let color = ThemeColor::new(cx);
+
+        h_stack()
+            .z_index(5)
+            .absolute()
+            .top_1()
+            .right_1()
+            .w(rems(9999.))
+            .max_w_56()
+            .py_1()
+            .px_1p5()
+            .rounded_lg()
+            .shadow_md()
+            .bg(color.elevated_surface)
+            .child(div().size_full().child(self.label.clone()))
+    }
+}

crates/ui2/src/components/notifications_panel.rs 🔗

@@ -0,0 +1,80 @@
+use std::marker::PhantomData;
+
+use crate::{prelude::*, static_new_notification_items, static_read_notification_items};
+use crate::{List, ListHeader};
+
+#[derive(Element)]
+pub struct NotificationsPanel<S: 'static + Send + Sync + Clone> {
+    state_type: PhantomData<S>,
+}
+
+impl<S: 'static + Send + Sync + Clone> NotificationsPanel<S> {
+    pub fn new() -> Self {
+        Self {
+            state_type: PhantomData,
+        }
+    }
+
+    fn render(&mut self, _view: &mut S, cx: &mut ViewContext<S>) -> impl Element<ViewState = S> {
+        let color = ThemeColor::new(cx);
+
+        div()
+            .flex()
+            .flex_col()
+            .w_full()
+            .h_full()
+            .bg(color.surface)
+            .child(
+                div()
+                    .w_full()
+                    .flex()
+                    .flex_col()
+                    .overflow_y_scroll(ScrollState::default())
+                    .child(
+                        List::new(static_new_notification_items())
+                            .header(ListHeader::new("NEW").set_toggle(ToggleState::Toggled))
+                            .set_toggle(ToggleState::Toggled),
+                    )
+                    .child(
+                        List::new(static_read_notification_items())
+                            .header(ListHeader::new("EARLIER").set_toggle(ToggleState::Toggled))
+                            .empty_message("No new notifications")
+                            .set_toggle(ToggleState::Toggled),
+                    ),
+            )
+    }
+}
+
+#[cfg(feature = "stories")]
+pub use stories::*;
+
+#[cfg(feature = "stories")]
+mod stories {
+    use crate::{Panel, Story};
+
+    use super::*;
+
+    #[derive(Element)]
+    pub struct NotificationsPanelStory<S: 'static + Send + Sync + Clone> {
+        state_type: PhantomData<S>,
+    }
+
+    impl<S: 'static + Send + Sync + Clone> NotificationsPanelStory<S> {
+        pub fn new() -> Self {
+            Self {
+                state_type: PhantomData,
+            }
+        }
+
+        fn render(
+            &mut self,
+            _view: &mut S,
+            cx: &mut ViewContext<S>,
+        ) -> impl Element<ViewState = S> {
+            Story::container(cx)
+                .child(Story::title_for::<_, NotificationsPanel<S>>(cx))
+                .child(Story::label(cx, "Default"))
+                .child(Panel::new(cx).child(NotificationsPanel::new()))
+        }
+    }
+}

crates/ui2/src/components/workspace.rs 🔗

@@ -3,7 +3,7 @@ use std::sync::Arc;
 use chrono::DateTime;
 use gpui3::{px, relative, rems, view, Context, Size, View};
 
-use crate::prelude::*;
+use crate::{prelude::*, NotificationToast, NotificationsPanel};
 use crate::{
     static_livestream, theme, user_settings_mut, v_stack, AssistantPanel, Button, ChatMessage,
     ChatPanel, CollabPanel, EditorPane, FakeSettings, Label, LanguageSelector, Pane, PaneGroup,
@@ -249,6 +249,9 @@ impl Workspace {
                         )
                         .filter(|_| self.is_collab_panel_open()),
                     )
+                    // .child(NotificationToast::new(
+                    //     "maxbrunsfeld has requested to add you as a contact.".into(),
+                    // ))
                     .child(
                         v_stack()
                             .flex_1()
@@ -289,7 +292,7 @@ impl Workspace {
                         Some(
                             Panel::new(cx)
                                 .side(PanelSide::Right)
-                                .child(div().w_96().h_full().child("Notifications")),
+                                .child(NotificationsPanel::new()),
                         )
                         .filter(|_| self.is_notifications_panel_open()),
                     )

crates/ui2/src/elements/button.rs 🔗

@@ -197,6 +197,31 @@ impl<S: 'static + Send + Sync> Button<S> {
     }
 }
 
+#[derive(Element)]
+pub struct ButtonGroup<S: 'static + Send + Sync> {
+    state_type: PhantomData<S>,
+    buttons: Vec<Button<S>>,
+}
+
+impl<S: 'static + Send + Sync> ButtonGroup<S> {
+    pub fn new(buttons: Vec<Button<S>>) -> Self {
+        Self {
+            state_type: PhantomData,
+            buttons,
+        }
+    }
+
+    fn render(&mut self, _view: &mut S, cx: &mut ViewContext<S>) -> impl Element<ViewState = S> {
+        let mut el = h_stack().text_size(ui_size(cx, 1.));
+
+        for button in &mut self.buttons {
+            el = el.child(button.render(_view, cx));
+        }
+
+        el
+    }
+}
+
 #[cfg(feature = "stories")]
 pub use stories::*;
 

crates/ui2/src/elements/details.rs 🔗

@@ -1,12 +1,13 @@
 use std::marker::PhantomData;
 
-use crate::prelude::*;
+use crate::{prelude::*, v_stack, ButtonGroup};
 
 #[derive(Element)]
 pub struct Details<S: 'static + Send + Sync> {
     state_type: PhantomData<S>,
     text: &'static str,
     meta: Option<&'static str>,
+    actions: Option<ButtonGroup<S>>,
 }
 
 impl<S: 'static + Send + Sync> Details<S> {
@@ -15,6 +16,7 @@ impl<S: 'static + Send + Sync> Details<S> {
             state_type: PhantomData,
             text,
             meta: None,
+            actions: None,
         }
     }
 
@@ -23,18 +25,22 @@ impl<S: 'static + Send + Sync> Details<S> {
         self
     }
 
+    pub fn actions(mut self, actions: ButtonGroup<S>) -> Self {
+        self.actions = Some(actions);
+        self
+    }
+
     fn render(&mut self, _view: &mut S, cx: &mut ViewContext<S>) -> impl Element<ViewState = S> {
         let color = ThemeColor::new(cx);
 
-        div()
-            // .flex()
-            // .w_full()
+        v_stack()
             .p_1()
             .gap_0p5()
             .text_xs()
             .text_color(color.text)
             .child(self.text)
             .children(self.meta.map(|m| m))
+            .children(self.actions.take().map(|a| a))
     }
 }
 

crates/ui2/src/prelude.rs 🔗

@@ -190,7 +190,7 @@ impl ThemeColor {
             border_variant: theme.lowest.variant.default.border,
             border_focused: theme.lowest.accent.default.border,
             border_transparent: system_color.transparent,
-            elevated_surface: theme.middle.base.default.background,
+            elevated_surface: theme.lowest.base.default.background,
             surface: theme.middle.base.default.background,
             background: theme.lowest.base.default.background,
             filled_element: theme.lowest.base.default.background,
@@ -397,6 +397,12 @@ pub enum DisclosureControlStyle {
     None,
 }
 
+#[derive(Debug, PartialEq, Eq, Clone, Copy, EnumIter)]
+pub enum OverflowStyle {
+    Hidden,
+    Wrap,
+}
+
 #[derive(Default, PartialEq, Copy, Clone, EnumIter, strum::Display)]
 pub enum InteractionState {
     #[default]

crates/ui2/src/static_data.rs 🔗

@@ -4,13 +4,13 @@ use std::str::FromStr;
 use gpui3::WindowContext;
 use rand::Rng;
 
-use crate::HighlightedText;
 use crate::{
-    Buffer, BufferRow, BufferRows, EditorPane, FileSystemStatus, GitStatus, HighlightedLine, Icon,
-    Keybinding, Label, LabelColor, ListEntry, ListEntrySize, ListItem, Livestream, MicStatus,
-    ModifierKeys, PaletteItem, Player, PlayerCallStatus, PlayerWithCallStatus, ScreenShareStatus,
-    Symbol, Tab, ThemeColor, ToggleState, VideoStatus,
+    Buffer, BufferRow, BufferRows, Button, EditorPane, FileSystemStatus, GitStatus,
+    HighlightedLine, Icon, Keybinding, Label, LabelColor, ListEntry, ListEntrySize, ListItem,
+    Livestream, MicStatus, ModifierKeys, PaletteItem, Player, PlayerCallStatus,
+    PlayerWithCallStatus, ScreenShareStatus, Symbol, Tab, ThemeColor, ToggleState, VideoStatus,
 };
+use crate::{HighlightedText, ListDetailsEntry};
 
 pub fn static_tabs_example<S: 'static + Send + Sync + Clone>() -> Vec<Tab<S>> {
     vec![
@@ -324,6 +324,34 @@ pub fn static_players_with_call_status() -> Vec<PlayerWithCallStatus> {
     ]
 }
 
+pub fn static_new_notification_items<S: 'static + Send + Sync + Clone>() -> Vec<ListItem<S>> {
+    vec![
+        ListEntry::new(Label::new(
+            "maxdeviant invited you to join a stream in #design.",
+        ))
+        .set_left_icon(Icon::FileLock.into()),
+        ListEntry::new(Label::new("nathansobo accepted your contact request."))
+            .set_left_icon(Icon::FileToml.into()),
+    ]
+    .into_iter()
+    .map(From::from)
+    .collect()
+}
+
+pub fn static_read_notification_items<S: 'static + Send + Sync + Clone>() -> Vec<ListItem<S>> {
+    vec![
+        ListDetailsEntry::new("mikaylamaki added you as a contact.")
+            .actions(vec![Button::new("Decline"), Button::new("Accept")]),
+        ListDetailsEntry::new("maxdeviant invited you to a stream in #design.")
+            .seen(true)
+            .meta("This stream has ended."),
+        ListDetailsEntry::new("nathansobo accepted your contact request."),
+    ]
+    .into_iter()
+    .map(From::from)
+    .collect()
+}
+
 pub fn static_project_panel_project_items<S: 'static + Send + Sync + Clone>() -> Vec<ListItem<S>> {
     vec![
         ListEntry::new(Label::new("zed"))