Add StatusToast & the ToastLayer (#26232)

https://github.com/user-attachments/assets/b16e32e6-46c6-41dc-ab68-1824d288c8c2

This PR adds the first part of our planned extended notification system:
StatusToasts.

It also makes various updates to ComponentPreview and adds a `Styled`
extension in `ui::style::animation` to make it easier to animate styled
elements.

_**Note**: We will be very, very selective with what elements are
allowed to be animated in Zed. Assume PRs adding animation to elements
will all need to be manually signed off on by a designer._

## Status Toast

![CleanShot 2025-03-06 at 14 15
52@2x](https://github.com/user-attachments/assets/b65d4661-f8d1-4e98-b9be-2c05cba1409f)

These are designed to be used for notifying about things that don't
require an action to be taken or don't need to be triaged. They are
designed to be ignorable, and dismiss themselves automatically after a
set time.

They can optionally include a single action. 

Example: When the user enables Vim Mode, that action might let them undo
enabling it.

![CleanShot 2025-03-06 at 14 18
34@2x](https://github.com/user-attachments/assets/eb6cb20e-c968-4f03-88a5-ecb6a8809150)

Status Toasts should _not_ be used when an action is required, or for
any binary choice.

If the user must provide some input, this isn't the right component!

### Out of scope

- Toasts should fade over a short time (like AnimationDuration::Fast or
Instant) when dismissed
- We should visually show when the toast will dismiss. We'll need to
pipe the `duration_remaining` from the toast layer -> ActiveToast to do
this.
- Dismiss any active toast if another notification kind is created, like
a Notification or Alert.

Release Notes:

- N/A

---------

Co-authored-by: Cole Miller <m@cole-miller.net>
This commit is contained in:
Nate Butler 2025-03-06 15:37:54 -05:00 committed by GitHub
parent b8a8b9c699
commit 7e964290bf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1308 additions and 75 deletions

8
Cargo.lock generated
View file

@ -3046,8 +3046,12 @@ dependencies = [
name = "component_preview"
version = "0.1.0"
dependencies = [
"client",
"component",
"gpui",
"languages",
"notifications",
"project",
"ui",
"workspace",
]
@ -8378,13 +8382,17 @@ dependencies = [
"channel",
"client",
"collections",
"component",
"db",
"gpui",
"linkme",
"rpc",
"settings",
"sum_tree",
"time",
"ui",
"util",
"workspace",
]
[[package]]

View file

@ -1,3 +1,4 @@
use std::fmt::Display;
use std::ops::{Deref, DerefMut};
use std::sync::LazyLock;
@ -8,7 +9,7 @@ use parking_lot::RwLock;
use theme::ActiveTheme;
pub trait Component {
fn scope() -> Option<&'static str>;
fn scope() -> Option<ComponentScope>;
fn name() -> &'static str {
std::any::type_name::<Self>()
}
@ -31,7 +32,7 @@ pub static COMPONENT_DATA: LazyLock<RwLock<ComponentRegistry>> =
LazyLock::new(|| RwLock::new(ComponentRegistry::new()));
pub struct ComponentRegistry {
components: Vec<(Option<&'static str>, &'static str, Option<&'static str>)>,
components: Vec<(Option<ComponentScope>, &'static str, Option<&'static str>)>,
previews: HashMap<&'static str, fn(&mut Window, &mut App) -> AnyElement>,
}
@ -78,7 +79,7 @@ pub struct ComponentId(pub &'static str);
#[derive(Clone)]
pub struct ComponentMetadata {
name: SharedString,
scope: Option<SharedString>,
scope: Option<ComponentScope>,
description: Option<SharedString>,
preview: Option<fn(&mut Window, &mut App) -> AnyElement>,
}
@ -88,7 +89,7 @@ impl ComponentMetadata {
self.name.clone()
}
pub fn scope(&self) -> Option<SharedString> {
pub fn scope(&self) -> Option<ComponentScope> {
self.scope.clone()
}
@ -152,14 +153,14 @@ pub fn components() -> AllComponents {
let data = COMPONENT_DATA.read();
let mut all_components = AllComponents::new();
for &(scope, name, description) in &data.components {
let scope = scope.map(Into::into);
for (ref scope, name, description) in &data.components {
let preview = data.previews.get(name).cloned();
let component_name = SharedString::new_static(name);
all_components.insert(
ComponentId(name),
ComponentMetadata {
name: name.into(),
scope,
name: component_name,
scope: scope.clone(),
description: description.map(Into::into),
preview,
},
@ -169,6 +170,59 @@ pub fn components() -> AllComponents {
all_components
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ComponentScope {
Layout,
Input,
Notification,
Editor,
Collaboration,
VersionControl,
Unknown(SharedString),
}
impl Display for ComponentScope {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ComponentScope::Layout => write!(f, "Layout"),
ComponentScope::Input => write!(f, "Input"),
ComponentScope::Notification => write!(f, "Notification"),
ComponentScope::Editor => write!(f, "Editor"),
ComponentScope::Collaboration => write!(f, "Collaboration"),
ComponentScope::VersionControl => write!(f, "Version Control"),
ComponentScope::Unknown(name) => write!(f, "Unknown: {}", name),
}
}
}
impl From<&str> for ComponentScope {
fn from(value: &str) -> Self {
match value {
"Layout" => ComponentScope::Layout,
"Input" => ComponentScope::Input,
"Notification" => ComponentScope::Notification,
"Editor" => ComponentScope::Editor,
"Collaboration" => ComponentScope::Collaboration,
"Version Control" | "VersionControl" => ComponentScope::VersionControl,
_ => ComponentScope::Unknown(SharedString::new(value)),
}
}
}
impl From<String> for ComponentScope {
fn from(value: String) -> Self {
match value.as_str() {
"Layout" => ComponentScope::Layout,
"Input" => ComponentScope::Input,
"Notification" => ComponentScope::Notification,
"Editor" => ComponentScope::Editor,
"Collaboration" => ComponentScope::Collaboration,
"Version Control" | "VersionControl" => ComponentScope::VersionControl,
_ => ComponentScope::Unknown(SharedString::new(value)),
}
}
}
/// Which side of the preview to show labels on
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExampleLabelSide {
@ -177,8 +231,8 @@ pub enum ExampleLabelSide {
/// Right side
Right,
/// Top side
Top,
#[default]
Top,
/// Bottom side
Bottom,
}
@ -208,6 +262,7 @@ impl RenderOnce for ComponentExample {
.text_size(px(10.))
.text_color(cx.theme().colors().text_muted)
.when(self.grow, |this| this.flex_1())
.when(!self.grow, |this| this.flex_none())
.child(self.element)
.child(self.variant_name)
.into_any_element()

View file

@ -15,7 +15,11 @@ path = "src/component_preview.rs"
default = []
[dependencies]
client.workspace = true
component.workspace = true
gpui.workspace = true
languages.workspace = true
project.workspace = true
ui.workspace = true
workspace.workspace = true
notifications.workspace = true

View file

@ -2,18 +2,49 @@
//!
//! A view for exploring Zed components.
use std::iter::Iterator;
use std::sync::Arc;
use client::UserStore;
use component::{components, ComponentMetadata};
use gpui::{list, prelude::*, uniform_list, App, EventEmitter, FocusHandle, Focusable, Window};
use gpui::{
list, prelude::*, uniform_list, App, Entity, EventEmitter, FocusHandle, Focusable, Task,
WeakEntity, Window,
};
use gpui::{ListState, ScrollHandle, UniformListScrollHandle};
use ui::{prelude::*, ListItem};
use languages::LanguageRegistry;
use notifications::status_toast::{StatusToast, ToastIcon};
use project::Project;
use ui::{prelude::*, Divider, ListItem, ListSubHeader};
use workspace::{item::ItemEvent, Item, Workspace, WorkspaceId};
use workspace::{AppState, ItemId, SerializableItem};
pub fn init(app_state: Arc<AppState>, cx: &mut App) {
let app_state = app_state.clone();
cx.observe_new(move |workspace: &mut Workspace, _, cx| {
let app_state = app_state.clone();
let weak_workspace = cx.entity().downgrade();
pub fn init(cx: &mut App) {
cx.observe_new(|workspace: &mut Workspace, _, _cx| {
workspace.register_action(
|workspace, _: &workspace::OpenComponentPreview, window, cx| {
let component_preview = cx.new(|cx| ComponentPreview::new(window, cx));
move |workspace, _: &workspace::OpenComponentPreview, window, cx| {
let app_state = app_state.clone();
let language_registry = app_state.languages.clone();
let user_store = app_state.user_store.clone();
let component_preview = cx.new(|cx| {
ComponentPreview::new(
weak_workspace.clone(),
language_registry,
user_store,
None,
cx,
)
});
workspace.add_item_to_active_pane(
Box::new(component_preview),
None,
@ -27,6 +58,23 @@ pub fn init(cx: &mut App) {
.detach();
}
enum PreviewEntry {
Component(ComponentMetadata),
SectionHeader(SharedString),
}
impl From<ComponentMetadata> for PreviewEntry {
fn from(component: ComponentMetadata) -> Self {
PreviewEntry::Component(component)
}
}
impl From<SharedString> for PreviewEntry {
fn from(section_header: SharedString) -> Self {
PreviewEntry::SectionHeader(section_header)
}
}
struct ComponentPreview {
focus_handle: FocusHandle,
_view_scroll_handle: ScrollHandle,
@ -34,31 +82,55 @@ struct ComponentPreview {
components: Vec<ComponentMetadata>,
component_list: ListState,
selected_index: usize,
language_registry: Arc<LanguageRegistry>,
workspace: WeakEntity<Workspace>,
user_store: Entity<UserStore>,
}
impl ComponentPreview {
pub fn new(_window: &mut Window, cx: &mut Context<Self>) -> Self {
pub fn new(
workspace: WeakEntity<Workspace>,
language_registry: Arc<LanguageRegistry>,
user_store: Entity<UserStore>,
selected_index: impl Into<Option<usize>>,
cx: &mut Context<Self>,
) -> Self {
let components = components().all_sorted();
let initial_length = components.len();
let selected_index = selected_index.into().unwrap_or(0);
let component_list = ListState::new(initial_length, gpui::ListAlignment::Top, px(500.0), {
let this = cx.entity().downgrade();
move |ix, window: &mut Window, cx: &mut App| {
this.update(cx, |this, cx| {
this.render_preview(ix, window, cx).into_any_element()
})
.unwrap()
}
});
let component_list =
ListState::new(initial_length, gpui::ListAlignment::Top, px(1500.0), {
let this = cx.entity().downgrade();
move |ix, window: &mut Window, cx: &mut App| {
this.update(cx, |this, cx| {
let component = this.get_component(ix);
this.render_preview(ix, &component, window, cx)
.into_any_element()
})
.unwrap()
}
});
Self {
let mut component_preview = Self {
focus_handle: cx.focus_handle(),
_view_scroll_handle: ScrollHandle::new(),
nav_scroll_handle: UniformListScrollHandle::new(),
language_registry,
user_store,
workspace,
components,
component_list,
selected_index: 0,
selected_index,
};
if component_preview.selected_index > 0 {
component_preview.scroll_to_preview(component_preview.selected_index, cx);
}
component_preview.update_component_list(cx);
component_preview
}
fn scroll_to_preview(&mut self, ix: usize, cx: &mut Context<Self>) {
@ -71,32 +143,158 @@ impl ComponentPreview {
self.components[ix].clone()
}
fn scope_ordered_entries(&self) -> Vec<PreviewEntry> {
use std::collections::HashMap;
// Group components by scope
let mut scope_groups: HashMap<Option<ComponentScope>, Vec<ComponentMetadata>> =
HashMap::default();
for component in &self.components {
scope_groups
.entry(component.scope())
.or_insert_with(Vec::new)
.push(component.clone());
}
// Sort components within each scope by name
for components in scope_groups.values_mut() {
components.sort_by_key(|c| c.name().to_lowercase());
}
// Build entries with scopes in a defined order
let mut entries = Vec::new();
// Define scope order (we want Unknown at the end)
let known_scopes = [
ComponentScope::Layout,
ComponentScope::Input,
ComponentScope::Editor,
ComponentScope::Notification,
ComponentScope::Collaboration,
ComponentScope::VersionControl,
];
// First add components with known scopes
for scope in known_scopes.iter() {
let scope_key = Some(scope.clone());
if let Some(components) = scope_groups.remove(&scope_key) {
if !components.is_empty() {
// Add section header
entries.push(PreviewEntry::SectionHeader(scope.to_string().into()));
// Add all components under this scope
for component in components {
entries.push(PreviewEntry::Component(component));
}
}
}
}
// Handle components with Unknown scope
for (scope, components) in &scope_groups {
if let Some(ComponentScope::Unknown(_)) = scope {
if !components.is_empty() {
// Add the unknown scope header
if let Some(scope_value) = scope {
entries.push(PreviewEntry::SectionHeader(scope_value.to_string().into()));
}
// Add all components under this unknown scope
for component in components {
entries.push(PreviewEntry::Component(component.clone()));
}
}
}
}
// Handle components with no scope
if let Some(components) = scope_groups.get(&None) {
if !components.is_empty() {
entries.push(PreviewEntry::SectionHeader("Uncategorized".into()));
for component in components {
entries.push(PreviewEntry::Component(component.clone()));
}
}
}
entries
}
fn render_sidebar_entry(
&self,
ix: usize,
entry: &PreviewEntry,
selected: bool,
cx: &Context<Self>,
) -> impl IntoElement {
let component = self.get_component(ix);
match entry {
PreviewEntry::Component(component_metadata) => ListItem::new(ix)
.child(Label::new(component_metadata.name().clone()).color(Color::Default))
.selectable(true)
.toggle_state(selected)
.inset(true)
.on_click(cx.listener(move |this, _, _, cx| {
this.scroll_to_preview(ix, cx);
}))
.into_any_element(),
PreviewEntry::SectionHeader(shared_string) => ListSubHeader::new(shared_string)
.inset(true)
.into_any_element(),
}
}
ListItem::new(ix)
.child(Label::new(component.name().clone()).color(Color::Default))
.selectable(true)
.toggle_state(selected)
.inset(true)
.on_click(cx.listener(move |this, _, _, cx| {
this.scroll_to_preview(ix, cx);
}))
fn update_component_list(&mut self, cx: &mut Context<Self>) {
let new_len = self.scope_ordered_entries().len();
let entries = self.scope_ordered_entries();
let weak_entity = cx.entity().downgrade();
let new_list = ListState::new(
new_len,
gpui::ListAlignment::Top,
px(1500.0),
move |ix, window, cx| {
let entry = &entries[ix];
weak_entity
.update(cx, |this, cx| match entry {
PreviewEntry::Component(component) => this
.render_preview(ix, component, window, cx)
.into_any_element(),
PreviewEntry::SectionHeader(shared_string) => this
.render_scope_header(ix, shared_string.clone(), window, cx)
.into_any_element(),
})
.unwrap()
},
);
self.component_list = new_list;
}
fn render_scope_header(
&self,
_ix: usize,
title: SharedString,
_window: &Window,
_cx: &App,
) -> impl IntoElement {
h_flex()
.w_full()
.h_10()
.items_center()
.child(Headline::new(title).size(HeadlineSize::XSmall))
.child(Divider::horizontal())
}
fn render_preview(
&self,
ix: usize,
_ix: usize,
component: &ComponentMetadata,
window: &mut Window,
cx: &mut Context<Self>,
cx: &mut App,
) -> impl IntoElement {
let component = self.get_component(ix);
let name = component.name();
let scope = component.scope();
@ -142,10 +340,32 @@ impl ComponentPreview {
)
.into_any_element()
}
fn test_status_toast(&self, window: &mut Window, cx: &mut Context<Self>) {
if let Some(workspace) = self.workspace.upgrade() {
workspace.update(cx, |workspace, cx| {
let status_toast = StatusToast::new(
"`zed/new-notification-system` created!",
window,
cx,
|this, _, cx| {
this.icon(ToastIcon::new(IconName::GitBranchSmall).color(Color::Muted))
.action(
"Open Pull Request",
cx.listener(|_, _, _, cx| cx.open_url("https://github.com/")),
)
},
);
workspace.toggle_status_toast(window, cx, status_toast)
});
}
}
}
impl Render for ComponentPreview {
fn render(&mut self, _window: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement {
let sidebar_entries = self.scope_ordered_entries();
h_flex()
.id("component-preview")
.key_context("ComponentPreview")
@ -156,21 +376,44 @@ impl Render for ComponentPreview {
.px_2()
.bg(cx.theme().colors().editor_background)
.child(
uniform_list(
cx.entity().clone(),
"component-nav",
self.components.len(),
move |this, range, _window, cx| {
range
.map(|ix| this.render_sidebar_entry(ix, ix == this.selected_index, cx))
.collect()
},
)
.track_scroll(self.nav_scroll_handle.clone())
.pt_4()
.w(px(240.))
.h_full()
.flex_grow(),
v_flex()
.h_full()
.child(
uniform_list(
cx.entity().clone(),
"component-nav",
sidebar_entries.len(),
move |this, range, _window, cx| {
range
.map(|ix| {
this.render_sidebar_entry(
ix,
&sidebar_entries[ix],
ix == this.selected_index,
cx,
)
})
.collect()
},
)
.track_scroll(self.nav_scroll_handle.clone())
.pt_4()
.w(px(240.))
.h_full()
.flex_1(),
)
.child(
div().w_full().pb_4().child(
Button::new("toast-test", "Launch Toast")
.on_click(cx.listener({
move |this, _, window, cx| {
this.test_status_toast(window, cx);
cx.notify();
}
}))
.full_width(),
),
),
)
.child(
v_flex()
@ -213,13 +456,26 @@ impl Item for ComponentPreview {
fn clone_on_split(
&self,
_workspace_id: Option<WorkspaceId>,
window: &mut Window,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Option<gpui::Entity<Self>>
where
Self: Sized,
{
Some(cx.new(|cx| Self::new(window, cx)))
let language_registry = self.language_registry.clone();
let user_store = self.user_store.clone();
let weak_workspace = self.workspace.clone();
let selected_index = self.selected_index;
Some(cx.new(|cx| {
Self::new(
weak_workspace,
language_registry,
user_store,
selected_index,
cx,
)
}))
}
fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
@ -227,6 +483,59 @@ impl Item for ComponentPreview {
}
}
// TODO: impl serializable item for component preview so it will restore with the workspace
// ref: https://github.com/zed-industries/zed/blob/32201ac70a501e63dfa2ade9c00f85aea2d4dd94/crates/image_viewer/src/image_viewer.rs#L199
// Use `ImageViewer` as a model for how to do it, except it'll be even simpler
impl SerializableItem for ComponentPreview {
fn serialized_item_kind() -> &'static str {
"ComponentPreview"
}
fn deserialize(
project: Entity<Project>,
workspace: WeakEntity<Workspace>,
_workspace_id: WorkspaceId,
_item_id: ItemId,
window: &mut Window,
cx: &mut App,
) -> Task<gpui::Result<Entity<Self>>> {
let user_store = project.read(cx).user_store().clone();
let language_registry = project.read(cx).languages().clone();
window.spawn(cx, |mut cx| async move {
let user_store = user_store.clone();
let language_registry = language_registry.clone();
let weak_workspace = workspace.clone();
cx.update(|_, cx| {
Ok(cx.new(|cx| {
ComponentPreview::new(weak_workspace, language_registry, user_store, None, cx)
}))
})?
})
}
fn cleanup(
_workspace_id: WorkspaceId,
_alive_items: Vec<ItemId>,
_window: &mut Window,
_cx: &mut App,
) -> Task<gpui::Result<()>> {
Task::ready(Ok(()))
// window.spawn(cx, |_| {
// ...
// })
}
fn serialize(
&mut self,
_workspace: &mut Workspace,
_item_id: ItemId,
_closing: bool,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> Option<Task<gpui::Result<()>>> {
// TODO: Serialize the active index so we can re-open to the same place
None
}
fn should_serialize(&self, _event: &Self::Event) -> bool {
false
}
}

View file

@ -3301,7 +3301,7 @@ fn render_git_action_menu(id: impl Into<ElementId>) -> impl IntoElement {
}
#[derive(IntoElement, IntoComponent)]
#[component(scope = "git_panel")]
#[component(scope = "Version Control")]
pub struct PanelRepoFooter {
id: SharedString,
active_repository: SharedString,

View file

@ -188,6 +188,11 @@ mod easing {
}
}
/// The Quint ease-out function, which starts quickly and decelerates to a stop
pub fn ease_out_quint() -> impl Fn(f32) -> f32 {
move |delta| 1.0 - (1.0 - delta).powi(5)
}
/// Apply the given easing function, first in the forward direction and then in the reverse direction
pub fn bounce(easing: impl Fn(f32) -> f32) -> impl Fn(f32) -> f32 {
move |delta| {

View file

@ -9,7 +9,7 @@ license = "GPL-3.0-or-later"
workspace = true
[lib]
path = "src/notification_store.rs"
path = "src/notifications.rs"
doctest = false
[features]
@ -25,12 +25,16 @@ anyhow.workspace = true
channel.workspace = true
client.workspace = true
collections.workspace = true
component.workspace = true
db.workspace = true
gpui.workspace = true
linkme.workspace = true
rpc.workspace = true
sum_tree.workspace = true
time.workspace = true
ui.workspace = true
util.workspace = true
workspace.workspace = true
[dev-dependencies]
client = { workspace = true, features = ["test-support"] }

View file

@ -0,0 +1,4 @@
mod notification_store;
pub use notification_store::*;
pub mod status_toast;

View file

@ -0,0 +1,223 @@
use std::sync::Arc;
use gpui::{ClickEvent, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, IntoElement};
use ui::prelude::*;
use workspace::ToastView;
#[derive(Clone)]
pub struct ToastAction {
id: ElementId,
label: SharedString,
on_click: Option<Arc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
}
#[derive(Clone, Copy)]
pub struct ToastIcon {
icon: IconName,
color: Color,
}
impl ToastIcon {
pub fn new(icon: IconName) -> Self {
Self {
icon,
color: Color::default(),
}
}
pub fn color(mut self, color: Color) -> Self {
self.color = color;
self
}
}
impl From<IconName> for ToastIcon {
fn from(icon: IconName) -> Self {
Self {
icon,
color: Color::default(),
}
}
}
impl ToastAction {
pub fn new(
label: SharedString,
on_click: Option<Arc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
) -> Self {
let id = ElementId::Name(label.clone());
Self {
id,
label,
on_click,
}
}
}
#[derive(IntoComponent)]
#[component(scope = "Notification")]
pub struct StatusToast {
icon: Option<ToastIcon>,
text: SharedString,
action: Option<ToastAction>,
focus_handle: FocusHandle,
}
impl StatusToast {
pub fn new(
text: impl Into<SharedString>,
window: &mut Window,
cx: &mut App,
f: impl FnOnce(Self, &mut Window, &mut Context<Self>) -> Self,
) -> Entity<Self> {
cx.new(|cx| {
let focus_handle = cx.focus_handle();
window.refresh();
f(
Self {
text: text.into(),
icon: None,
action: None,
focus_handle,
},
window,
cx,
)
})
}
pub fn icon(mut self, icon: ToastIcon) -> Self {
self.icon = Some(icon);
self
}
pub fn action(
mut self,
label: impl Into<SharedString>,
f: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.action = Some(ToastAction::new(label.into(), Some(Arc::new(f))));
self
}
}
impl Render for StatusToast {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
h_flex()
.id("status-toast")
.elevation_3(cx)
.gap_2()
.py_1p5()
.px_2p5()
.flex_none()
.bg(cx.theme().colors().surface_background)
.shadow_lg()
.items_center()
.when_some(self.icon.as_ref(), |this, icon| {
this.child(Icon::new(icon.icon).color(icon.color))
})
.child(Label::new(self.text.clone()).color(Color::Default))
.when_some(self.action.as_ref(), |this, action| {
this.child(
Button::new(action.id.clone(), action.label.clone())
.color(Color::Muted)
.when_some(action.on_click.clone(), |el, handler| {
el.on_click(move |click_event, window, cx| {
handler(click_event, window, cx)
})
}),
)
})
}
}
impl ToastView for StatusToast {}
impl Focusable for StatusToast {
fn focus_handle(&self, _cx: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl EventEmitter<DismissEvent> for StatusToast {}
impl ComponentPreview for StatusToast {
fn preview(window: &mut Window, cx: &mut App) -> AnyElement {
let text_example = StatusToast::new("Operation completed", window, cx, |this, _, _| this);
let action_example =
StatusToast::new("Update ready to install", window, cx, |this, _, cx| {
this.action("Restart", cx.listener(|_, _, _, _| {}))
});
let icon_example = StatusToast::new(
"Nathan Sobo accepted your contact request",
window,
cx,
|this, _, _| this.icon(ToastIcon::new(IconName::Check).color(Color::Muted)),
);
let success_example = StatusToast::new(
"Pushed 4 changes to `zed/main`",
window,
cx,
|this, _, _| this.icon(ToastIcon::new(IconName::Check).color(Color::Success)),
);
let error_example = StatusToast::new(
"git push: Couldn't find remote origin `iamnbutler/zed`",
window,
cx,
|this, _, cx| {
this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error))
.action("More Info", cx.listener(|_, _, _, _| {}))
},
);
let warning_example =
StatusToast::new("You have outdated settings", window, cx, |this, _, cx| {
this.icon(ToastIcon::new(IconName::Warning).color(Color::Warning))
.action("More Info", cx.listener(|_, _, _, _| {}))
});
let pr_example = StatusToast::new(
"`zed/new-notification-system` created!",
window,
cx,
|this, _, cx| {
this.icon(ToastIcon::new(IconName::GitBranchSmall).color(Color::Muted))
.action(
"Open Pull Request",
cx.listener(|_, _, _, cx| cx.open_url("https://github.com/")),
)
},
);
v_flex()
.gap_6()
.p_4()
.children(vec![
example_group_with_title(
"Basic Toast",
vec![
single_example("Text", div().child(text_example).into_any_element()),
single_example("Action", div().child(action_example).into_any_element()),
single_example("Icon", div().child(icon_example).into_any_element()),
],
),
example_group_with_title(
"Examples",
vec![
single_example("Success", div().child(success_example).into_any_element()),
single_example("Error", div().child(error_example).into_any_element()),
single_example("Warning", div().child(warning_example).into_any_element()),
single_example("Create PR", div().child(pr_example).into_any_element()),
],
)
.vertical(),
])
.into_any_element()
}
}

View file

@ -17,6 +17,7 @@ mod label;
mod list;
mod modal;
mod navigable;
mod notification;
mod numeric_stepper;
mod popover;
mod popover_menu;
@ -54,6 +55,7 @@ pub use label::*;
pub use list::*;
pub use modal::*;
pub use navigable::*;
pub use notification::*;
pub use numeric_stepper::*;
pub use popover::*;
pub use popover_menu::*;

View file

@ -80,7 +80,7 @@ use super::button_icon::ButtonIcon;
/// ```
///
#[derive(IntoElement, IntoComponent)]
#[component(scope = "input")]
#[component(scope = "Input")]
pub struct Button {
base: ButtonLike,
label: SharedString,

View file

@ -14,7 +14,7 @@ pub enum IconButtonShape {
}
#[derive(IntoElement, IntoComponent)]
#[component(scope = "input")]
#[component(scope = "Input")]
pub struct IconButton {
base: ButtonLike,
shape: IconButtonShape,

View file

@ -16,7 +16,7 @@ pub enum ToggleButtonPosition {
}
#[derive(IntoElement, IntoComponent)]
#[component(scope = "input")]
#[component(scope = "Input")]
pub struct ToggleButton {
base: ButtonLike,
position_in_group: Option<ToggleButtonPosition>,

View file

@ -24,7 +24,7 @@ pub fn h_container() -> ContentGroup {
/// A flexible container component that can hold other elements.
#[derive(IntoElement, IntoComponent)]
#[component(scope = "layout")]
#[component(scope = "Layout")]
pub struct ContentGroup {
base: Div,
border: bool,

View file

@ -0,0 +1,3 @@
mod alert_modal;
pub use alert_modal::*;

View file

@ -0,0 +1,99 @@
use crate::prelude::*;
use gpui::IntoElement;
use smallvec::{smallvec, SmallVec};
#[derive(IntoElement, IntoComponent)]
#[component(scope = "Notification")]
pub struct AlertModal {
id: ElementId,
children: SmallVec<[AnyElement; 2]>,
title: SharedString,
primary_action: SharedString,
dismiss_label: SharedString,
}
impl AlertModal {
pub fn new(id: impl Into<ElementId>, title: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
children: smallvec![],
title: title.into(),
primary_action: "Ok".into(),
dismiss_label: "Cancel".into(),
}
}
pub fn primary_action(mut self, primary_action: impl Into<SharedString>) -> Self {
self.primary_action = primary_action.into();
self
}
pub fn dismiss_label(mut self, dismiss_label: impl Into<SharedString>) -> Self {
self.dismiss_label = dismiss_label.into();
self
}
}
impl RenderOnce for AlertModal {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
v_flex()
.id(self.id)
.elevation_3(cx)
.w(px(440.))
.p_5()
.child(
v_flex()
.text_ui(cx)
.text_color(Color::Muted.color(cx))
.gap_1()
.child(Headline::new(self.title).size(HeadlineSize::Small))
.children(self.children),
)
.child(
h_flex()
.h(rems(1.75))
.items_center()
.child(div().flex_1())
.child(
h_flex()
.items_center()
.gap_1()
.child(
Button::new(self.dismiss_label.clone(), self.dismiss_label.clone())
.color(Color::Muted),
)
.child(Button::new(
self.primary_action.clone(),
self.primary_action.clone(),
)),
),
)
}
}
impl ParentElement for AlertModal {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements)
}
}
impl ComponentPreview for AlertModal {
fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement {
v_flex()
.gap_6()
.p_4()
.children(vec![example_group(
vec![
single_example(
"Basic Alert",
AlertModal::new("simple-modal", "Do you want to leave the current call?")
.child("The current window will be closed, and connections to any shared projects will be terminated."
)
.primary_action("Leave Call")
.into_any_element(),
)
],
)])
.into_any_element()
}
}

View file

@ -40,7 +40,7 @@ pub enum ToggleStyle {
/// Each checkbox works independently from other checkboxes in the list,
/// therefore checking an additional box does not affect any other selections.
#[derive(IntoElement, IntoComponent)]
#[component(scope = "input")]
#[component(scope = "Input")]
pub struct Checkbox {
id: ElementId,
toggle_state: ToggleState,
@ -240,7 +240,7 @@ impl RenderOnce for Checkbox {
/// A [`Checkbox`] that has a [`Label`].
#[derive(IntoElement, IntoComponent)]
#[component(scope = "input")]
#[component(scope = "Input")]
pub struct CheckboxWithLabel {
id: ElementId,
label: Label,
@ -318,7 +318,7 @@ impl RenderOnce for CheckboxWithLabel {
///
/// Switches are used to represent opposite states, such as enabled or disabled.
#[derive(IntoElement, IntoComponent)]
#[component(scope = "input")]
#[component(scope = "Input")]
pub struct Switch {
id: ElementId,
toggle_state: ToggleState,

View file

@ -7,9 +7,12 @@ pub use gpui::{
Styled, Window,
};
pub use component::{example_group, example_group_with_title, single_example, ComponentPreview};
pub use component::{
example_group, example_group_with_title, single_example, ComponentPreview, ComponentScope,
};
pub use ui_macros::IntoComponent;
pub use crate::animation::{AnimationDirection, AnimationDuration, DefaultAnimations};
pub use crate::styles::{rems_from_px, vh, vw, PlatformStyle, StyledTypography, TextSize};
pub use crate::traits::clickable::*;
pub use crate::traits::disableable::*;

View file

@ -1,3 +1,4 @@
pub mod animation;
mod appearance;
mod color;
mod elevation;

View file

@ -0,0 +1,276 @@
use crate::{prelude::*, ContentGroup};
use gpui::{AnimationElement, AnimationExt, Styled};
use std::time::Duration;
use gpui::ease_out_quint;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum AnimationDuration {
Instant = 50,
Fast = 150,
Slow = 300,
}
impl AnimationDuration {
pub fn duration(&self) -> Duration {
Duration::from_millis(*self as u64)
}
}
impl Into<std::time::Duration> for AnimationDuration {
fn into(self) -> Duration {
self.duration()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum AnimationDirection {
FromBottom,
FromLeft,
FromRight,
FromTop,
}
pub trait DefaultAnimations: Styled + Sized {
fn animate_in(
self,
animation_type: AnimationDirection,
fade_in: bool,
) -> AnimationElement<Self> {
let animation_name = match animation_type {
AnimationDirection::FromBottom => "animate_from_bottom",
AnimationDirection::FromLeft => "animate_from_left",
AnimationDirection::FromRight => "animate_from_right",
AnimationDirection::FromTop => "animate_from_top",
};
self.with_animation(
animation_name,
gpui::Animation::new(AnimationDuration::Fast.into()).with_easing(ease_out_quint()),
move |mut this, delta| {
let start_opacity = 0.4;
let start_pos = 0.0;
let end_pos = 40.0;
if fade_in {
this = this.opacity(start_opacity + delta * (1.0 - start_opacity));
}
match animation_type {
AnimationDirection::FromBottom => {
this.bottom(px(start_pos + delta * (end_pos - start_pos)))
}
AnimationDirection::FromLeft => {
this.left(px(start_pos + delta * (end_pos - start_pos)))
}
AnimationDirection::FromRight => {
this.right(px(start_pos + delta * (end_pos - start_pos)))
}
AnimationDirection::FromTop => {
this.top(px(start_pos + delta * (end_pos - start_pos)))
}
}
},
)
}
fn animate_in_from_bottom(self, fade: bool) -> AnimationElement<Self> {
self.animate_in(AnimationDirection::FromBottom, fade)
}
fn animate_in_from_left(self, fade: bool) -> AnimationElement<Self> {
self.animate_in(AnimationDirection::FromLeft, fade)
}
fn animate_in_from_right(self, fade: bool) -> AnimationElement<Self> {
self.animate_in(AnimationDirection::FromRight, fade)
}
fn animate_in_from_top(self, fade: bool) -> AnimationElement<Self> {
self.animate_in(AnimationDirection::FromTop, fade)
}
}
impl<E: Styled> DefaultAnimations for E {}
// Don't use this directly, it only exists to show animation previews
#[derive(IntoComponent)]
struct Animation {}
// View this component preview using `workspace: open component-preview`
impl ComponentPreview for Animation {
fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement {
let container_size = 128.0;
let element_size = 32.0;
let left_offset = element_size - container_size / 2.0;
v_flex()
.gap_6()
.children(vec![
example_group_with_title(
"Animate In",
vec![
single_example(
"From Bottom",
ContentGroup::new()
.relative()
.items_center()
.justify_center()
.size(px(container_size))
.child(
div()
.id("animate-in-from-bottom")
.absolute()
.size(px(element_size))
.left(px(left_offset))
.rounded_md()
.bg(gpui::red())
.animate_in(AnimationDirection::FromBottom, false),
)
.into_any_element(),
),
single_example(
"From Top",
ContentGroup::new()
.relative()
.items_center()
.justify_center()
.size(px(container_size))
.child(
div()
.id("animate-in-from-top")
.absolute()
.size(px(element_size))
.left(px(left_offset))
.rounded_md()
.bg(gpui::blue())
.animate_in(AnimationDirection::FromTop, false),
)
.into_any_element(),
),
single_example(
"From Left",
ContentGroup::new()
.relative()
.items_center()
.justify_center()
.size(px(container_size))
.child(
div()
.id("animate-in-from-left")
.absolute()
.size(px(element_size))
.left(px(left_offset))
.rounded_md()
.bg(gpui::green())
.animate_in(AnimationDirection::FromLeft, false),
)
.into_any_element(),
),
single_example(
"From Right",
ContentGroup::new()
.relative()
.items_center()
.justify_center()
.size(px(container_size))
.child(
div()
.id("animate-in-from-right")
.absolute()
.size(px(element_size))
.left(px(left_offset))
.rounded_md()
.bg(gpui::yellow())
.animate_in(AnimationDirection::FromRight, false),
)
.into_any_element(),
),
],
)
.grow(),
example_group_with_title(
"Fade and Animate In",
vec![
single_example(
"From Bottom",
ContentGroup::new()
.relative()
.items_center()
.justify_center()
.size(px(container_size))
.child(
div()
.id("fade-animate-in-from-bottom")
.absolute()
.size(px(element_size))
.left(px(left_offset))
.rounded_md()
.bg(gpui::red())
.animate_in(AnimationDirection::FromBottom, true),
)
.into_any_element(),
),
single_example(
"From Top",
ContentGroup::new()
.relative()
.items_center()
.justify_center()
.size(px(container_size))
.child(
div()
.id("fade-animate-in-from-top")
.absolute()
.size(px(element_size))
.left(px(left_offset))
.rounded_md()
.bg(gpui::blue())
.animate_in(AnimationDirection::FromTop, true),
)
.into_any_element(),
),
single_example(
"From Left",
ContentGroup::new()
.relative()
.items_center()
.justify_center()
.size(px(container_size))
.child(
div()
.id("fade-animate-in-from-left")
.absolute()
.size(px(element_size))
.left(px(left_offset))
.rounded_md()
.bg(gpui::green())
.animate_in(AnimationDirection::FromLeft, true),
)
.into_any_element(),
),
single_example(
"From Right",
ContentGroup::new()
.relative()
.items_center()
.justify_center()
.size(px(container_size))
.child(
div()
.id("fade-animate-in-from-right")
.absolute()
.size(px(element_size))
.left(px(left_offset))
.rounded_md()
.bg(gpui::yellow())
.animate_in(AnimationDirection::FromRight, true),
)
.into_any_element(),
),
],
)
.grow(),
])
.into_any_element()
}
}

View file

@ -33,14 +33,15 @@ pub fn derive_into_component(input: TokenStream) -> TokenStream {
let name = &input.ident;
let scope_impl = if let Some(s) = scope_val {
let scope_str = s.clone();
quote! {
fn scope() -> Option<&'static str> {
Some(#s)
fn scope() -> Option<component::ComponentScope> {
Some(component::ComponentScope::from(#scope_str))
}
}
} else {
quote! {
fn scope() -> Option<&'static str> {
fn scope() -> Option<component::ComponentScope> {
None
}
}

View file

@ -0,0 +1,216 @@
use std::time::{Duration, Instant};
use gpui::{AnyView, DismissEvent, Entity, FocusHandle, ManagedView, Subscription, Task};
use ui::{animation::DefaultAnimations, prelude::*};
const DEFAULT_TOAST_DURATION: Duration = Duration::from_millis(2400);
const MINIMUM_RESUME_DURATION: Duration = Duration::from_millis(800);
pub trait ToastView: ManagedView {}
trait ToastViewHandle {
fn view(&self) -> AnyView;
}
impl<V: ToastView> ToastViewHandle for Entity<V> {
fn view(&self) -> AnyView {
self.clone().into()
}
}
pub struct ActiveToast {
toast: Box<dyn ToastViewHandle>,
_subscriptions: [Subscription; 1],
focus_handle: FocusHandle,
}
struct DismissTimer {
instant_started: Instant,
_task: Task<()>,
}
pub struct ToastLayer {
active_toast: Option<ActiveToast>,
duration_remaining: Option<Duration>,
dismiss_timer: Option<DismissTimer>,
}
impl Default for ToastLayer {
fn default() -> Self {
Self::new()
}
}
impl ToastLayer {
pub fn new() -> Self {
Self {
active_toast: None,
duration_remaining: None,
dismiss_timer: None,
}
}
pub fn toggle_toast<V>(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
new_toast: Entity<V>,
) where
V: ToastView,
{
if let Some(active_toast) = &self.active_toast {
let is_close = active_toast.toast.view().downcast::<V>().is_ok();
let did_close = self.hide_toast(window, cx);
if is_close || !did_close {
return;
}
}
self.show_toast(new_toast, window, cx);
}
pub fn show_toast<V>(
&mut self,
new_toast: Entity<V>,
window: &mut Window,
cx: &mut Context<Self>,
) where
V: ToastView,
{
let focus_handle = cx.focus_handle();
self.active_toast = Some(ActiveToast {
toast: Box::new(new_toast.clone()),
_subscriptions: [cx.subscribe_in(
&new_toast,
window,
|this, _, _: &DismissEvent, window, cx| {
this.hide_toast(window, cx);
},
)],
focus_handle,
});
self.start_dismiss_timer(DEFAULT_TOAST_DURATION, window, cx);
cx.notify();
}
pub fn hide_toast(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> bool {
cx.notify();
true
}
pub fn active_toast<V>(&self) -> Option<Entity<V>>
where
V: 'static,
{
let active_toast = self.active_toast.as_ref()?;
active_toast.toast.view().downcast::<V>().ok()
}
pub fn has_active_toast(&self) -> bool {
self.active_toast.is_some()
}
fn pause_dismiss_timer(&mut self) {
let Some(dismiss_timer) = self.dismiss_timer.take() else {
return;
};
let Some(duration_remaining) = self.duration_remaining.as_mut() else {
return;
};
*duration_remaining =
duration_remaining.saturating_sub(dismiss_timer.instant_started.elapsed());
if *duration_remaining < MINIMUM_RESUME_DURATION {
*duration_remaining = MINIMUM_RESUME_DURATION;
}
}
/// Starts a timer to automatically dismiss the toast after the specified duration
pub fn start_dismiss_timer(
&mut self,
duration: Duration,
_window: &mut Window,
cx: &mut Context<Self>,
) {
self.clear_dismiss_timer(cx);
let instant_started = std::time::Instant::now();
let task = cx.spawn(|this, mut cx| async move {
cx.background_executor().timer(duration).await;
if let Some(this) = this.upgrade() {
this.update(&mut cx, |this, cx| {
this.active_toast.take();
cx.notify();
})
.ok();
}
});
self.duration_remaining = Some(duration);
self.dismiss_timer = Some(DismissTimer {
instant_started,
_task: task,
});
cx.notify();
}
/// Restarts the dismiss timer with a new duration
pub fn restart_dismiss_timer(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(duration) = self.duration_remaining else {
return;
};
self.start_dismiss_timer(duration, window, cx);
cx.notify();
}
/// Clears the dismiss timer if one exists
pub fn clear_dismiss_timer(&mut self, cx: &mut Context<Self>) {
self.dismiss_timer.take();
cx.notify();
}
}
impl Render for ToastLayer {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let Some(active_toast) = &self.active_toast else {
return div();
};
let handle = cx.weak_entity();
div().absolute().size_full().bottom_0().left_0().child(
v_flex()
.id("toast-layer-container")
.absolute()
.w_full()
.bottom(px(0.))
.flex()
.flex_col()
.items_center()
.track_focus(&active_toast.focus_handle)
.child(
h_flex()
.id("active-toast-container")
.occlude()
.on_hover(move |hover_start, window, cx| {
let Some(this) = handle.upgrade() else {
return;
};
if *hover_start {
this.update(cx, |this, _| this.pause_dismiss_timer());
} else {
this.update(cx, |this, cx| this.restart_dismiss_timer(window, cx));
}
cx.stop_propagation();
})
.on_click(|_, _, cx| {
cx.stop_propagation();
})
.child(active_toast.toast.view()),
)
.animate_in(AnimationDirection::FromBottom, true),
)
}
}

View file

@ -10,9 +10,12 @@ pub mod shared_screen;
mod status_bar;
pub mod tasks;
mod theme_preview;
mod toast_layer;
mod toolbar;
mod workspace_settings;
pub use toast_layer::{ToastLayer, ToastView};
use anyhow::{anyhow, Context as _, Result};
use call::{call_settings::CallSettings, ActiveCall};
use client::{
@ -816,6 +819,7 @@ pub struct Workspace {
last_active_view_id: Option<proto::ViewId>,
status_bar: Entity<StatusBar>,
modal_layer: Entity<ModalLayer>,
toast_layer: Entity<ToastLayer>,
titlebar_item: Option<AnyView>,
notifications: Notifications,
project: Entity<Project>,
@ -1032,6 +1036,7 @@ impl Workspace {
});
let modal_layer = cx.new(|_| ModalLayer::new());
let toast_layer = cx.new(|_| ToastLayer::new());
let session_id = app_state.session.read(cx).id().to_owned();
@ -1112,6 +1117,7 @@ impl Workspace {
last_active_view_id: None,
status_bar,
modal_layer,
toast_layer,
titlebar_item: None,
notifications: Default::default(),
left_dock,
@ -4971,6 +4977,17 @@ impl Workspace {
})
}
pub fn toggle_status_toast<V: ToastView>(
&mut self,
window: &mut Window,
cx: &mut App,
entity: Entity<V>,
) {
self.toast_layer.update(cx, |toast_layer, cx| {
toast_layer.toggle_toast(window, cx, entity)
})
}
pub fn toggle_centered_layout(
&mut self,
_: &ToggleCenteredLayout,
@ -5485,7 +5502,8 @@ impl Render for Workspace {
.children(self.render_notifications(window, cx)),
)
.child(self.status_bar.clone())
.child(self.modal_layer.clone()),
.child(self.modal_layer.clone())
.child(self.toast_layer.clone()),
),
window,
cx,

View file

@ -507,7 +507,6 @@ fn main() {
project_symbols::init(cx);
project_panel::init(cx);
outline_panel::init(cx);
component_preview::init(cx);
tasks_ui::init(cx);
snippets_ui::init(cx);
channel::init(&app_state.client.clone(), app_state.user_store.clone(), cx);
@ -619,6 +618,9 @@ fn main() {
}
let app_state = app_state.clone();
component_preview::init(app_state.clone(), cx);
cx.spawn(move |cx| async move {
while let Some(urls) = open_rx.next().await {
cx.update(|cx| {