From 7e964290bfe869205952925e5a92a7e4b1222d88 Mon Sep 17 00:00:00 2001 From: Nate Butler Date: Thu, 6 Mar 2025 15:37:54 -0500 Subject: [PATCH] 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 --- Cargo.lock | 8 + crates/component/src/component.rs | 73 +++- crates/component_preview/Cargo.toml | 4 + .../src/component_preview.rs | 411 +++++++++++++++--- crates/git_ui/src/git_panel.rs | 2 +- crates/gpui/src/elements/animation.rs | 5 + crates/notifications/Cargo.toml | 6 +- crates/notifications/src/notifications.rs | 4 + crates/notifications/src/status_toast.rs | 223 ++++++++++ crates/ui/src/components.rs | 2 + crates/ui/src/components/button/button.rs | 2 +- .../ui/src/components/button/icon_button.rs | 2 +- .../ui/src/components/button/toggle_button.rs | 2 +- crates/ui/src/components/content_group.rs | 2 +- crates/ui/src/components/notification.rs | 3 + .../components/notification/alert_modal.rs | 99 +++++ crates/ui/src/components/toggle.rs | 6 +- crates/ui/src/prelude.rs | 5 +- crates/ui/src/styles.rs | 1 + crates/ui/src/styles/animation.rs | 276 ++++++++++++ crates/ui_macros/src/derive_component.rs | 7 +- crates/workspace/src/toast_layer.rs | 216 +++++++++ crates/workspace/src/workspace.rs | 20 +- crates/zed/src/main.rs | 4 +- 24 files changed, 1308 insertions(+), 75 deletions(-) create mode 100644 crates/notifications/src/notifications.rs create mode 100644 crates/notifications/src/status_toast.rs create mode 100644 crates/ui/src/components/notification.rs create mode 100644 crates/ui/src/components/notification/alert_modal.rs create mode 100644 crates/ui/src/styles/animation.rs create mode 100644 crates/workspace/src/toast_layer.rs diff --git a/Cargo.lock b/Cargo.lock index e85020e9bc..a62bf40066 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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]] diff --git a/crates/component/src/component.rs b/crates/component/src/component.rs index b0e92ce500..7360123925 100644 --- a/crates/component/src/component.rs +++ b/crates/component/src/component.rs @@ -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; fn name() -> &'static str { std::any::type_name::() } @@ -31,7 +32,7 @@ pub static COMPONENT_DATA: LazyLock> = LazyLock::new(|| RwLock::new(ComponentRegistry::new())); pub struct ComponentRegistry { - components: Vec<(Option<&'static str>, &'static str, Option<&'static str>)>, + components: Vec<(Option, &'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, + scope: Option, description: Option, preview: Option AnyElement>, } @@ -88,7 +89,7 @@ impl ComponentMetadata { self.name.clone() } - pub fn scope(&self) -> Option { + pub fn scope(&self) -> Option { 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 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() diff --git a/crates/component_preview/Cargo.toml b/crates/component_preview/Cargo.toml index d909991a18..b8d52f9370 100644 --- a/crates/component_preview/Cargo.toml +++ b/crates/component_preview/Cargo.toml @@ -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 diff --git a/crates/component_preview/src/component_preview.rs b/crates/component_preview/src/component_preview.rs index ff7112ffc1..bf795c3547 100644 --- a/crates/component_preview/src/component_preview.rs +++ b/crates/component_preview/src/component_preview.rs @@ -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, 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 for PreviewEntry { + fn from(component: ComponentMetadata) -> Self { + PreviewEntry::Component(component) + } +} + +impl From 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, component_list: ListState, selected_index: usize, + language_registry: Arc, + workspace: WeakEntity, + user_store: Entity, } impl ComponentPreview { - pub fn new(_window: &mut Window, cx: &mut Context) -> Self { + pub fn new( + workspace: WeakEntity, + language_registry: Arc, + user_store: Entity, + selected_index: impl Into>, + cx: &mut Context, + ) -> 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) { @@ -71,32 +143,158 @@ impl ComponentPreview { self.components[ix].clone() } + fn scope_ordered_entries(&self) -> Vec { + use std::collections::HashMap; + + // Group components by scope + let mut scope_groups: HashMap, Vec> = + 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, ) -> 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) { + 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, + 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) { + 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, - window: &mut Window, + _window: &mut Window, cx: &mut Context, ) -> Option> 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, + workspace: WeakEntity, + _workspace_id: WorkspaceId, + _item_id: ItemId, + window: &mut Window, + cx: &mut App, + ) -> Task>> { + 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, + _window: &mut Window, + _cx: &mut App, + ) -> Task> { + Task::ready(Ok(())) + // window.spawn(cx, |_| { + // ... + // }) + } + + fn serialize( + &mut self, + _workspace: &mut Workspace, + _item_id: ItemId, + _closing: bool, + _window: &mut Window, + _cx: &mut Context, + ) -> Option>> { + // TODO: Serialize the active index so we can re-open to the same place + None + } + + fn should_serialize(&self, _event: &Self::Event) -> bool { + false + } +} diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 3a7b711b93..20b32a5e69 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -3301,7 +3301,7 @@ fn render_git_action_menu(id: impl Into) -> impl IntoElement { } #[derive(IntoElement, IntoComponent)] -#[component(scope = "git_panel")] +#[component(scope = "Version Control")] pub struct PanelRepoFooter { id: SharedString, active_repository: SharedString, diff --git a/crates/gpui/src/elements/animation.rs b/crates/gpui/src/elements/animation.rs index d3ece10670..2db3d3c049 100644 --- a/crates/gpui/src/elements/animation.rs +++ b/crates/gpui/src/elements/animation.rs @@ -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| { diff --git a/crates/notifications/Cargo.toml b/crates/notifications/Cargo.toml index 79399f2e6d..443f3313dd 100644 --- a/crates/notifications/Cargo.toml +++ b/crates/notifications/Cargo.toml @@ -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"] } diff --git a/crates/notifications/src/notifications.rs b/crates/notifications/src/notifications.rs new file mode 100644 index 0000000000..ee952555eb --- /dev/null +++ b/crates/notifications/src/notifications.rs @@ -0,0 +1,4 @@ +mod notification_store; + +pub use notification_store::*; +pub mod status_toast; diff --git a/crates/notifications/src/status_toast.rs b/crates/notifications/src/status_toast.rs new file mode 100644 index 0000000000..cb67662f8e --- /dev/null +++ b/crates/notifications/src/status_toast.rs @@ -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>, +} + +#[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 for ToastIcon { + fn from(icon: IconName) -> Self { + Self { + icon, + color: Color::default(), + } + } +} + +impl ToastAction { + pub fn new( + label: SharedString, + on_click: Option>, + ) -> Self { + let id = ElementId::Name(label.clone()); + + Self { + id, + label, + on_click, + } + } +} + +#[derive(IntoComponent)] +#[component(scope = "Notification")] +pub struct StatusToast { + icon: Option, + text: SharedString, + action: Option, + focus_handle: FocusHandle, +} + +impl StatusToast { + pub fn new( + text: impl Into, + window: &mut Window, + cx: &mut App, + f: impl FnOnce(Self, &mut Window, &mut Context) -> Self, + ) -> Entity { + 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, + 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) -> 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 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() + } +} diff --git a/crates/ui/src/components.rs b/crates/ui/src/components.rs index 4ad5899837..2264ae8537 100644 --- a/crates/ui/src/components.rs +++ b/crates/ui/src/components.rs @@ -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::*; diff --git a/crates/ui/src/components/button/button.rs b/crates/ui/src/components/button/button.rs index 621ae419dd..27c7509944 100644 --- a/crates/ui/src/components/button/button.rs +++ b/crates/ui/src/components/button/button.rs @@ -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, diff --git a/crates/ui/src/components/button/icon_button.rs b/crates/ui/src/components/button/icon_button.rs index 5326137efc..8adda25042 100644 --- a/crates/ui/src/components/button/icon_button.rs +++ b/crates/ui/src/components/button/icon_button.rs @@ -14,7 +14,7 @@ pub enum IconButtonShape { } #[derive(IntoElement, IntoComponent)] -#[component(scope = "input")] +#[component(scope = "Input")] pub struct IconButton { base: ButtonLike, shape: IconButtonShape, diff --git a/crates/ui/src/components/button/toggle_button.rs b/crates/ui/src/components/button/toggle_button.rs index 618fa176bd..7e114b989c 100644 --- a/crates/ui/src/components/button/toggle_button.rs +++ b/crates/ui/src/components/button/toggle_button.rs @@ -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, diff --git a/crates/ui/src/components/content_group.rs b/crates/ui/src/components/content_group.rs index 9bf0ce2cd5..2c5fd88847 100644 --- a/crates/ui/src/components/content_group.rs +++ b/crates/ui/src/components/content_group.rs @@ -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, diff --git a/crates/ui/src/components/notification.rs b/crates/ui/src/components/notification.rs new file mode 100644 index 0000000000..61109550f7 --- /dev/null +++ b/crates/ui/src/components/notification.rs @@ -0,0 +1,3 @@ +mod alert_modal; + +pub use alert_modal::*; diff --git a/crates/ui/src/components/notification/alert_modal.rs b/crates/ui/src/components/notification/alert_modal.rs new file mode 100644 index 0000000000..5685c197a2 --- /dev/null +++ b/crates/ui/src/components/notification/alert_modal.rs @@ -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, title: impl Into) -> 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) -> Self { + self.primary_action = primary_action.into(); + self + } + + pub fn dismiss_label(mut self, dismiss_label: impl Into) -> 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) { + 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() + } +} diff --git a/crates/ui/src/components/toggle.rs b/crates/ui/src/components/toggle.rs index 18c148d365..f188d0d86b 100644 --- a/crates/ui/src/components/toggle.rs +++ b/crates/ui/src/components/toggle.rs @@ -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, diff --git a/crates/ui/src/prelude.rs b/crates/ui/src/prelude.rs index 0b9ce91f1e..54551daffb 100644 --- a/crates/ui/src/prelude.rs +++ b/crates/ui/src/prelude.rs @@ -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::*; diff --git a/crates/ui/src/styles.rs b/crates/ui/src/styles.rs index 91b016cdd8..af6ab57029 100644 --- a/crates/ui/src/styles.rs +++ b/crates/ui/src/styles.rs @@ -1,3 +1,4 @@ +pub mod animation; mod appearance; mod color; mod elevation; diff --git a/crates/ui/src/styles/animation.rs b/crates/ui/src/styles/animation.rs new file mode 100644 index 0000000000..d15a6c9f4c --- /dev/null +++ b/crates/ui/src/styles/animation.rs @@ -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 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 { + 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.animate_in(AnimationDirection::FromBottom, fade) + } + + fn animate_in_from_left(self, fade: bool) -> AnimationElement { + self.animate_in(AnimationDirection::FromLeft, fade) + } + + fn animate_in_from_right(self, fade: bool) -> AnimationElement { + self.animate_in(AnimationDirection::FromRight, fade) + } + + fn animate_in_from_top(self, fade: bool) -> AnimationElement { + self.animate_in(AnimationDirection::FromTop, fade) + } +} + +impl 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() + } +} diff --git a/crates/ui_macros/src/derive_component.rs b/crates/ui_macros/src/derive_component.rs index 5103d219c2..d49b45675f 100644 --- a/crates/ui_macros/src/derive_component.rs +++ b/crates/ui_macros/src/derive_component.rs @@ -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 { + Some(component::ComponentScope::from(#scope_str)) } } } else { quote! { - fn scope() -> Option<&'static str> { + fn scope() -> Option { None } } diff --git a/crates/workspace/src/toast_layer.rs b/crates/workspace/src/toast_layer.rs new file mode 100644 index 0000000000..1ebcda4c03 --- /dev/null +++ b/crates/workspace/src/toast_layer.rs @@ -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 ToastViewHandle for Entity { + fn view(&self) -> AnyView { + self.clone().into() + } +} + +pub struct ActiveToast { + toast: Box, + _subscriptions: [Subscription; 1], + focus_handle: FocusHandle, +} + +struct DismissTimer { + instant_started: Instant, + _task: Task<()>, +} + +pub struct ToastLayer { + active_toast: Option, + duration_remaining: Option, + dismiss_timer: Option, +} + +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( + &mut self, + window: &mut Window, + cx: &mut Context, + new_toast: Entity, + ) where + V: ToastView, + { + if let Some(active_toast) = &self.active_toast { + let is_close = active_toast.toast.view().downcast::().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( + &mut self, + new_toast: Entity, + window: &mut Window, + cx: &mut Context, + ) 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) -> bool { + cx.notify(); + + true + } + + pub fn active_toast(&self) -> Option> + where + V: 'static, + { + let active_toast = self.active_toast.as_ref()?; + active_toast.toast.view().downcast::().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.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) { + 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.dismiss_timer.take(); + cx.notify(); + } +} + +impl Render for ToastLayer { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> 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), + ) + } +} diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index d8eed0ecda..4614807156 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -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, status_bar: Entity, modal_layer: Entity, + toast_layer: Entity, titlebar_item: Option, notifications: Notifications, project: Entity, @@ -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( + &mut self, + window: &mut Window, + cx: &mut App, + entity: Entity, + ) { + 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, diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index ed43aabbba..87d14e1da9 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -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| {