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 e85020e9bcc8c2f1b32916b331bfa02474b21613..a62bf40066d451c2ccf6bed3fda84b33ae14b6fc 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 b0e92ce500dc0d6181f78dea4cbe16d98c88ee47..73601239256f36c21ea3b377f40294a0b301f7ac 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 d909991a1893912ecd777d4503d983605ac85f05..b8d52f9370e5023180f2f8ddea37c911ca9e5f2f 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 ff7112ffc1641f537e5bd42bade57053145deef9..bf795c3547687d81f35fceb08fafc7e557fac399 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 3a7b711b9362119cfda332c8ef690287601b1fdd..20b32a5e692797437aee5a81086413145fac25c6 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 d3ece10670814ed77ed875e7449580e2e7a66b83..2db3d3c049582cf9d5d272071ee400dbf09a647b 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 79399f2e6d4abb4d721f055aedacb7b5e0738316..443f3313dd473a11e2b6819364a39a832efaaf81 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 0000000000000000000000000000000000000000..ee952555eb902e9c875f521317f2bb142b9c9d36 --- /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 0000000000000000000000000000000000000000..cb67662f8e69d1c95227840fe39232a5fc5b494f --- /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 4ad58998379a82881dcc330895bb59149048fa49..2264ae85379ad8388b0fc93a355851a6749c8a5b 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 621ae419dd49666e5caa66a74c4d184c29b1878d..27c7509944abf07c5d25e244b931330cc89e9d89 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 5326137efc1f83a7d1c1e85a84aff989a45a4902..8adda25042b4e2e11da7de057360ed7daa777e45 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 618fa176bda17ff191aa8a889635a39ff951d12a..7e114b989cc68e3e5f7e5c2a2cfa4ce75319ddf6 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 9bf0ce2cd5504253d101569f4fe4bbc5cd1d3a31..2c5fd88847a155910144c85aa67b2431cc2f5f5a 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 0000000000000000000000000000000000000000..61109550f7d396b23b87f5edd2c3ee12ac044d03 --- /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 0000000000000000000000000000000000000000..5685c197a2dcd8d5d0c65ab96ee7f8fffe303931 --- /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 18c148d3652d5d1c6491865757acbbe4a6093b58..f188d0d86bf10bc5b276700e149ab12bf5f7eb37 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 0b9ce91f1e9f15d7c316d4fb1816f53a3329e7dc..54551daffbe5a6050988543a37814fc904dda81a 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 91b016cdd8631cb942e86ddca2acc7fb2428bdbe..af6ab570291ecea442140326926d1a6d16b3781b 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 0000000000000000000000000000000000000000..d15a6c9f4ce45c14282912a286f5eefb481d009f --- /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 5103d219c2ba5c7193c6ee8885ea8f71db772fb1..d49b45675fdbe45c6130f2dcdaeaa79da32389a5 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 0000000000000000000000000000000000000000..1ebcda4c03ba02359002ab10b506b1d641cf5835 --- /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 d8eed0ecda4aeeff0c169a18a62c39003aa1ac99..4614807156c0cf559016a8f54cdba344f110c044 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 ed43aabbba76aa6273e3dc20c000a6617b13ba10..87d14e1da9402801d49357ac90256b3e88e14850 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| {