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| {