Detailed changes
@@ -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]]
@@ -1,3 +1,4 @@
+use std::fmt::Display;
use std::ops::{Deref, DerefMut};
use std::sync::LazyLock;
@@ -8,7 +9,7 @@ use parking_lot::RwLock;
use theme::ActiveTheme;
pub trait Component {
- fn scope() -> Option<&'static str>;
+ fn scope() -> Option<ComponentScope>;
fn name() -> &'static str {
std::any::type_name::<Self>()
}
@@ -31,7 +32,7 @@ pub static COMPONENT_DATA: LazyLock<RwLock<ComponentRegistry>> =
LazyLock::new(|| RwLock::new(ComponentRegistry::new()));
pub struct ComponentRegistry {
- components: Vec<(Option<&'static str>, &'static str, Option<&'static str>)>,
+ components: Vec<(Option<ComponentScope>, &'static str, Option<&'static str>)>,
previews: HashMap<&'static str, fn(&mut Window, &mut App) -> AnyElement>,
}
@@ -78,7 +79,7 @@ pub struct ComponentId(pub &'static str);
#[derive(Clone)]
pub struct ComponentMetadata {
name: SharedString,
- scope: Option<SharedString>,
+ scope: Option<ComponentScope>,
description: Option<SharedString>,
preview: Option<fn(&mut Window, &mut App) -> AnyElement>,
}
@@ -88,7 +89,7 @@ impl ComponentMetadata {
self.name.clone()
}
- pub fn scope(&self) -> Option<SharedString> {
+ pub fn scope(&self) -> Option<ComponentScope> {
self.scope.clone()
}
@@ -152,14 +153,14 @@ pub fn components() -> AllComponents {
let data = COMPONENT_DATA.read();
let mut all_components = AllComponents::new();
- for &(scope, name, description) in &data.components {
- let scope = scope.map(Into::into);
+ for (ref scope, name, description) in &data.components {
let preview = data.previews.get(name).cloned();
+ let component_name = SharedString::new_static(name);
all_components.insert(
ComponentId(name),
ComponentMetadata {
- name: name.into(),
- scope,
+ name: component_name,
+ scope: scope.clone(),
description: description.map(Into::into),
preview,
},
@@ -169,6 +170,59 @@ pub fn components() -> AllComponents {
all_components
}
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub enum ComponentScope {
+ Layout,
+ Input,
+ Notification,
+ Editor,
+ Collaboration,
+ VersionControl,
+ Unknown(SharedString),
+}
+
+impl Display for ComponentScope {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ ComponentScope::Layout => write!(f, "Layout"),
+ ComponentScope::Input => write!(f, "Input"),
+ ComponentScope::Notification => write!(f, "Notification"),
+ ComponentScope::Editor => write!(f, "Editor"),
+ ComponentScope::Collaboration => write!(f, "Collaboration"),
+ ComponentScope::VersionControl => write!(f, "Version Control"),
+ ComponentScope::Unknown(name) => write!(f, "Unknown: {}", name),
+ }
+ }
+}
+
+impl From<&str> for ComponentScope {
+ fn from(value: &str) -> Self {
+ match value {
+ "Layout" => ComponentScope::Layout,
+ "Input" => ComponentScope::Input,
+ "Notification" => ComponentScope::Notification,
+ "Editor" => ComponentScope::Editor,
+ "Collaboration" => ComponentScope::Collaboration,
+ "Version Control" | "VersionControl" => ComponentScope::VersionControl,
+ _ => ComponentScope::Unknown(SharedString::new(value)),
+ }
+ }
+}
+
+impl From<String> for ComponentScope {
+ fn from(value: String) -> Self {
+ match value.as_str() {
+ "Layout" => ComponentScope::Layout,
+ "Input" => ComponentScope::Input,
+ "Notification" => ComponentScope::Notification,
+ "Editor" => ComponentScope::Editor,
+ "Collaboration" => ComponentScope::Collaboration,
+ "Version Control" | "VersionControl" => ComponentScope::VersionControl,
+ _ => ComponentScope::Unknown(SharedString::new(value)),
+ }
+ }
+}
+
/// Which side of the preview to show labels on
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExampleLabelSide {
@@ -177,8 +231,8 @@ pub enum ExampleLabelSide {
/// Right side
Right,
/// Top side
- Top,
#[default]
+ Top,
/// Bottom side
Bottom,
}
@@ -208,6 +262,7 @@ impl RenderOnce for ComponentExample {
.text_size(px(10.))
.text_color(cx.theme().colors().text_muted)
.when(self.grow, |this| this.flex_1())
+ .when(!self.grow, |this| this.flex_none())
.child(self.element)
.child(self.variant_name)
.into_any_element()
@@ -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
@@ -2,18 +2,49 @@
//!
//! A view for exploring Zed components.
+use std::iter::Iterator;
+use std::sync::Arc;
+
+use client::UserStore;
use component::{components, ComponentMetadata};
-use gpui::{list, prelude::*, uniform_list, App, EventEmitter, FocusHandle, Focusable, Window};
+use gpui::{
+ list, prelude::*, uniform_list, App, Entity, EventEmitter, FocusHandle, Focusable, Task,
+ WeakEntity, Window,
+};
+
use gpui::{ListState, ScrollHandle, UniformListScrollHandle};
-use ui::{prelude::*, ListItem};
+use languages::LanguageRegistry;
+use notifications::status_toast::{StatusToast, ToastIcon};
+use project::Project;
+use ui::{prelude::*, Divider, ListItem, ListSubHeader};
use workspace::{item::ItemEvent, Item, Workspace, WorkspaceId};
+use workspace::{AppState, ItemId, SerializableItem};
+
+pub fn init(app_state: Arc<AppState>, cx: &mut App) {
+ let app_state = app_state.clone();
+
+ cx.observe_new(move |workspace: &mut Workspace, _, cx| {
+ let app_state = app_state.clone();
+ let weak_workspace = cx.entity().downgrade();
-pub fn init(cx: &mut App) {
- cx.observe_new(|workspace: &mut Workspace, _, _cx| {
workspace.register_action(
- |workspace, _: &workspace::OpenComponentPreview, window, cx| {
- let component_preview = cx.new(|cx| ComponentPreview::new(window, cx));
+ move |workspace, _: &workspace::OpenComponentPreview, window, cx| {
+ let app_state = app_state.clone();
+
+ let language_registry = app_state.languages.clone();
+ let user_store = app_state.user_store.clone();
+
+ let component_preview = cx.new(|cx| {
+ ComponentPreview::new(
+ weak_workspace.clone(),
+ language_registry,
+ user_store,
+ None,
+ cx,
+ )
+ });
+
workspace.add_item_to_active_pane(
Box::new(component_preview),
None,
@@ -27,6 +58,23 @@ pub fn init(cx: &mut App) {
.detach();
}
+enum PreviewEntry {
+ Component(ComponentMetadata),
+ SectionHeader(SharedString),
+}
+
+impl From<ComponentMetadata> for PreviewEntry {
+ fn from(component: ComponentMetadata) -> Self {
+ PreviewEntry::Component(component)
+ }
+}
+
+impl From<SharedString> for PreviewEntry {
+ fn from(section_header: SharedString) -> Self {
+ PreviewEntry::SectionHeader(section_header)
+ }
+}
+
struct ComponentPreview {
focus_handle: FocusHandle,
_view_scroll_handle: ScrollHandle,
@@ -34,31 +82,55 @@ struct ComponentPreview {
components: Vec<ComponentMetadata>,
component_list: ListState,
selected_index: usize,
+ language_registry: Arc<LanguageRegistry>,
+ workspace: WeakEntity<Workspace>,
+ user_store: Entity<UserStore>,
}
impl ComponentPreview {
- pub fn new(_window: &mut Window, cx: &mut Context<Self>) -> Self {
+ pub fn new(
+ workspace: WeakEntity<Workspace>,
+ language_registry: Arc<LanguageRegistry>,
+ user_store: Entity<UserStore>,
+ selected_index: impl Into<Option<usize>>,
+ cx: &mut Context<Self>,
+ ) -> Self {
let components = components().all_sorted();
let initial_length = components.len();
+ let selected_index = selected_index.into().unwrap_or(0);
- let component_list = ListState::new(initial_length, gpui::ListAlignment::Top, px(500.0), {
- let this = cx.entity().downgrade();
- move |ix, window: &mut Window, cx: &mut App| {
- this.update(cx, |this, cx| {
- this.render_preview(ix, window, cx).into_any_element()
- })
- .unwrap()
- }
- });
+ let component_list =
+ ListState::new(initial_length, gpui::ListAlignment::Top, px(1500.0), {
+ let this = cx.entity().downgrade();
+ move |ix, window: &mut Window, cx: &mut App| {
+ this.update(cx, |this, cx| {
+ let component = this.get_component(ix);
+ this.render_preview(ix, &component, window, cx)
+ .into_any_element()
+ })
+ .unwrap()
+ }
+ });
- Self {
+ let mut component_preview = Self {
focus_handle: cx.focus_handle(),
_view_scroll_handle: ScrollHandle::new(),
nav_scroll_handle: UniformListScrollHandle::new(),
+ language_registry,
+ user_store,
+ workspace,
components,
component_list,
- selected_index: 0,
+ selected_index,
+ };
+
+ if component_preview.selected_index > 0 {
+ component_preview.scroll_to_preview(component_preview.selected_index, cx);
}
+
+ component_preview.update_component_list(cx);
+
+ component_preview
}
fn scroll_to_preview(&mut self, ix: usize, cx: &mut Context<Self>) {
@@ -71,32 +143,158 @@ impl ComponentPreview {
self.components[ix].clone()
}
+ fn scope_ordered_entries(&self) -> Vec<PreviewEntry> {
+ use std::collections::HashMap;
+
+ // Group components by scope
+ let mut scope_groups: HashMap<Option<ComponentScope>, Vec<ComponentMetadata>> =
+ HashMap::default();
+
+ for component in &self.components {
+ scope_groups
+ .entry(component.scope())
+ .or_insert_with(Vec::new)
+ .push(component.clone());
+ }
+
+ // Sort components within each scope by name
+ for components in scope_groups.values_mut() {
+ components.sort_by_key(|c| c.name().to_lowercase());
+ }
+
+ // Build entries with scopes in a defined order
+ let mut entries = Vec::new();
+
+ // Define scope order (we want Unknown at the end)
+ let known_scopes = [
+ ComponentScope::Layout,
+ ComponentScope::Input,
+ ComponentScope::Editor,
+ ComponentScope::Notification,
+ ComponentScope::Collaboration,
+ ComponentScope::VersionControl,
+ ];
+
+ // First add components with known scopes
+ for scope in known_scopes.iter() {
+ let scope_key = Some(scope.clone());
+ if let Some(components) = scope_groups.remove(&scope_key) {
+ if !components.is_empty() {
+ // Add section header
+ entries.push(PreviewEntry::SectionHeader(scope.to_string().into()));
+
+ // Add all components under this scope
+ for component in components {
+ entries.push(PreviewEntry::Component(component));
+ }
+ }
+ }
+ }
+
+ // Handle components with Unknown scope
+ for (scope, components) in &scope_groups {
+ if let Some(ComponentScope::Unknown(_)) = scope {
+ if !components.is_empty() {
+ // Add the unknown scope header
+ if let Some(scope_value) = scope {
+ entries.push(PreviewEntry::SectionHeader(scope_value.to_string().into()));
+ }
+
+ // Add all components under this unknown scope
+ for component in components {
+ entries.push(PreviewEntry::Component(component.clone()));
+ }
+ }
+ }
+ }
+
+ // Handle components with no scope
+ if let Some(components) = scope_groups.get(&None) {
+ if !components.is_empty() {
+ entries.push(PreviewEntry::SectionHeader("Uncategorized".into()));
+
+ for component in components {
+ entries.push(PreviewEntry::Component(component.clone()));
+ }
+ }
+ }
+
+ entries
+ }
+
fn render_sidebar_entry(
&self,
ix: usize,
+ entry: &PreviewEntry,
selected: bool,
cx: &Context<Self>,
) -> impl IntoElement {
- let component = self.get_component(ix);
+ match entry {
+ PreviewEntry::Component(component_metadata) => ListItem::new(ix)
+ .child(Label::new(component_metadata.name().clone()).color(Color::Default))
+ .selectable(true)
+ .toggle_state(selected)
+ .inset(true)
+ .on_click(cx.listener(move |this, _, _, cx| {
+ this.scroll_to_preview(ix, cx);
+ }))
+ .into_any_element(),
+ PreviewEntry::SectionHeader(shared_string) => ListSubHeader::new(shared_string)
+ .inset(true)
+ .into_any_element(),
+ }
+ }
- ListItem::new(ix)
- .child(Label::new(component.name().clone()).color(Color::Default))
- .selectable(true)
- .toggle_state(selected)
- .inset(true)
- .on_click(cx.listener(move |this, _, _, cx| {
- this.scroll_to_preview(ix, cx);
- }))
+ fn update_component_list(&mut self, cx: &mut Context<Self>) {
+ let new_len = self.scope_ordered_entries().len();
+ let entries = self.scope_ordered_entries();
+ let weak_entity = cx.entity().downgrade();
+
+ let new_list = ListState::new(
+ new_len,
+ gpui::ListAlignment::Top,
+ px(1500.0),
+ move |ix, window, cx| {
+ let entry = &entries[ix];
+
+ weak_entity
+ .update(cx, |this, cx| match entry {
+ PreviewEntry::Component(component) => this
+ .render_preview(ix, component, window, cx)
+ .into_any_element(),
+ PreviewEntry::SectionHeader(shared_string) => this
+ .render_scope_header(ix, shared_string.clone(), window, cx)
+ .into_any_element(),
+ })
+ .unwrap()
+ },
+ );
+
+ self.component_list = new_list;
+ }
+
+ fn render_scope_header(
+ &self,
+ _ix: usize,
+ title: SharedString,
+ _window: &Window,
+ _cx: &App,
+ ) -> impl IntoElement {
+ h_flex()
+ .w_full()
+ .h_10()
+ .items_center()
+ .child(Headline::new(title).size(HeadlineSize::XSmall))
+ .child(Divider::horizontal())
}
fn render_preview(
&self,
- ix: usize,
+ _ix: usize,
+ component: &ComponentMetadata,
window: &mut Window,
- cx: &mut Context<Self>,
+ cx: &mut App,
) -> impl IntoElement {
- let component = self.get_component(ix);
-
let name = component.name();
let scope = component.scope();
@@ -142,10 +340,32 @@ impl ComponentPreview {
)
.into_any_element()
}
+
+ fn test_status_toast(&self, window: &mut Window, cx: &mut Context<Self>) {
+ if let Some(workspace) = self.workspace.upgrade() {
+ workspace.update(cx, |workspace, cx| {
+ let status_toast = StatusToast::new(
+ "`zed/new-notification-system` created!",
+ window,
+ cx,
+ |this, _, cx| {
+ this.icon(ToastIcon::new(IconName::GitBranchSmall).color(Color::Muted))
+ .action(
+ "Open Pull Request",
+ cx.listener(|_, _, _, cx| cx.open_url("https://github.com/")),
+ )
+ },
+ );
+ workspace.toggle_status_toast(window, cx, status_toast)
+ });
+ }
+ }
}
impl Render for ComponentPreview {
fn render(&mut self, _window: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement {
+ let sidebar_entries = self.scope_ordered_entries();
+
h_flex()
.id("component-preview")
.key_context("ComponentPreview")
@@ -156,21 +376,44 @@ impl Render for ComponentPreview {
.px_2()
.bg(cx.theme().colors().editor_background)
.child(
- uniform_list(
- cx.entity().clone(),
- "component-nav",
- self.components.len(),
- move |this, range, _window, cx| {
- range
- .map(|ix| this.render_sidebar_entry(ix, ix == this.selected_index, cx))
- .collect()
- },
- )
- .track_scroll(self.nav_scroll_handle.clone())
- .pt_4()
- .w(px(240.))
- .h_full()
- .flex_grow(),
+ v_flex()
+ .h_full()
+ .child(
+ uniform_list(
+ cx.entity().clone(),
+ "component-nav",
+ sidebar_entries.len(),
+ move |this, range, _window, cx| {
+ range
+ .map(|ix| {
+ this.render_sidebar_entry(
+ ix,
+ &sidebar_entries[ix],
+ ix == this.selected_index,
+ cx,
+ )
+ })
+ .collect()
+ },
+ )
+ .track_scroll(self.nav_scroll_handle.clone())
+ .pt_4()
+ .w(px(240.))
+ .h_full()
+ .flex_1(),
+ )
+ .child(
+ div().w_full().pb_4().child(
+ Button::new("toast-test", "Launch Toast")
+ .on_click(cx.listener({
+ move |this, _, window, cx| {
+ this.test_status_toast(window, cx);
+ cx.notify();
+ }
+ }))
+ .full_width(),
+ ),
+ ),
)
.child(
v_flex()
@@ -213,13 +456,26 @@ impl Item for ComponentPreview {
fn clone_on_split(
&self,
_workspace_id: Option<WorkspaceId>,
- window: &mut Window,
+ _window: &mut Window,
cx: &mut Context<Self>,
) -> Option<gpui::Entity<Self>>
where
Self: Sized,
{
- Some(cx.new(|cx| Self::new(window, cx)))
+ let language_registry = self.language_registry.clone();
+ let user_store = self.user_store.clone();
+ let weak_workspace = self.workspace.clone();
+ let selected_index = self.selected_index;
+
+ Some(cx.new(|cx| {
+ Self::new(
+ weak_workspace,
+ language_registry,
+ user_store,
+ selected_index,
+ cx,
+ )
+ }))
}
fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) {
@@ -227,6 +483,59 @@ impl Item for ComponentPreview {
}
}
-// TODO: impl serializable item for component preview so it will restore with the workspace
-// ref: https://github.com/zed-industries/zed/blob/32201ac70a501e63dfa2ade9c00f85aea2d4dd94/crates/image_viewer/src/image_viewer.rs#L199
-// Use `ImageViewer` as a model for how to do it, except it'll be even simpler
+impl SerializableItem for ComponentPreview {
+ fn serialized_item_kind() -> &'static str {
+ "ComponentPreview"
+ }
+
+ fn deserialize(
+ project: Entity<Project>,
+ workspace: WeakEntity<Workspace>,
+ _workspace_id: WorkspaceId,
+ _item_id: ItemId,
+ window: &mut Window,
+ cx: &mut App,
+ ) -> Task<gpui::Result<Entity<Self>>> {
+ let user_store = project.read(cx).user_store().clone();
+ let language_registry = project.read(cx).languages().clone();
+
+ window.spawn(cx, |mut cx| async move {
+ let user_store = user_store.clone();
+ let language_registry = language_registry.clone();
+ let weak_workspace = workspace.clone();
+ cx.update(|_, cx| {
+ Ok(cx.new(|cx| {
+ ComponentPreview::new(weak_workspace, language_registry, user_store, None, cx)
+ }))
+ })?
+ })
+ }
+
+ fn cleanup(
+ _workspace_id: WorkspaceId,
+ _alive_items: Vec<ItemId>,
+ _window: &mut Window,
+ _cx: &mut App,
+ ) -> Task<gpui::Result<()>> {
+ Task::ready(Ok(()))
+ // window.spawn(cx, |_| {
+ // ...
+ // })
+ }
+
+ fn serialize(
+ &mut self,
+ _workspace: &mut Workspace,
+ _item_id: ItemId,
+ _closing: bool,
+ _window: &mut Window,
+ _cx: &mut Context<Self>,
+ ) -> Option<Task<gpui::Result<()>>> {
+ // TODO: Serialize the active index so we can re-open to the same place
+ None
+ }
+
+ fn should_serialize(&self, _event: &Self::Event) -> bool {
+ false
+ }
+}
@@ -3301,7 +3301,7 @@ fn render_git_action_menu(id: impl Into<ElementId>) -> impl IntoElement {
}
#[derive(IntoElement, IntoComponent)]
-#[component(scope = "git_panel")]
+#[component(scope = "Version Control")]
pub struct PanelRepoFooter {
id: SharedString,
active_repository: SharedString,
@@ -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| {
@@ -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"] }
@@ -0,0 +1,4 @@
+mod notification_store;
+
+pub use notification_store::*;
+pub mod status_toast;
@@ -0,0 +1,223 @@
+use std::sync::Arc;
+
+use gpui::{ClickEvent, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, IntoElement};
+use ui::prelude::*;
+use workspace::ToastView;
+
+#[derive(Clone)]
+pub struct ToastAction {
+ id: ElementId,
+ label: SharedString,
+ on_click: Option<Arc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
+}
+
+#[derive(Clone, Copy)]
+pub struct ToastIcon {
+ icon: IconName,
+ color: Color,
+}
+
+impl ToastIcon {
+ pub fn new(icon: IconName) -> Self {
+ Self {
+ icon,
+ color: Color::default(),
+ }
+ }
+
+ pub fn color(mut self, color: Color) -> Self {
+ self.color = color;
+ self
+ }
+}
+
+impl From<IconName> for ToastIcon {
+ fn from(icon: IconName) -> Self {
+ Self {
+ icon,
+ color: Color::default(),
+ }
+ }
+}
+
+impl ToastAction {
+ pub fn new(
+ label: SharedString,
+ on_click: Option<Arc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
+ ) -> Self {
+ let id = ElementId::Name(label.clone());
+
+ Self {
+ id,
+ label,
+ on_click,
+ }
+ }
+}
+
+#[derive(IntoComponent)]
+#[component(scope = "Notification")]
+pub struct StatusToast {
+ icon: Option<ToastIcon>,
+ text: SharedString,
+ action: Option<ToastAction>,
+ focus_handle: FocusHandle,
+}
+
+impl StatusToast {
+ pub fn new(
+ text: impl Into<SharedString>,
+ window: &mut Window,
+ cx: &mut App,
+ f: impl FnOnce(Self, &mut Window, &mut Context<Self>) -> Self,
+ ) -> Entity<Self> {
+ cx.new(|cx| {
+ let focus_handle = cx.focus_handle();
+
+ window.refresh();
+ f(
+ Self {
+ text: text.into(),
+ icon: None,
+ action: None,
+ focus_handle,
+ },
+ window,
+ cx,
+ )
+ })
+ }
+
+ pub fn icon(mut self, icon: ToastIcon) -> Self {
+ self.icon = Some(icon);
+ self
+ }
+
+ pub fn action(
+ mut self,
+ label: impl Into<SharedString>,
+ f: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ ) -> Self {
+ self.action = Some(ToastAction::new(label.into(), Some(Arc::new(f))));
+ self
+ }
+}
+
+impl Render for StatusToast {
+ fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+ h_flex()
+ .id("status-toast")
+ .elevation_3(cx)
+ .gap_2()
+ .py_1p5()
+ .px_2p5()
+ .flex_none()
+ .bg(cx.theme().colors().surface_background)
+ .shadow_lg()
+ .items_center()
+ .when_some(self.icon.as_ref(), |this, icon| {
+ this.child(Icon::new(icon.icon).color(icon.color))
+ })
+ .child(Label::new(self.text.clone()).color(Color::Default))
+ .when_some(self.action.as_ref(), |this, action| {
+ this.child(
+ Button::new(action.id.clone(), action.label.clone())
+ .color(Color::Muted)
+ .when_some(action.on_click.clone(), |el, handler| {
+ el.on_click(move |click_event, window, cx| {
+ handler(click_event, window, cx)
+ })
+ }),
+ )
+ })
+ }
+}
+
+impl ToastView for StatusToast {}
+
+impl Focusable for StatusToast {
+ fn focus_handle(&self, _cx: &App) -> gpui::FocusHandle {
+ self.focus_handle.clone()
+ }
+}
+
+impl EventEmitter<DismissEvent> for StatusToast {}
+
+impl ComponentPreview for StatusToast {
+ fn preview(window: &mut Window, cx: &mut App) -> AnyElement {
+ let text_example = StatusToast::new("Operation completed", window, cx, |this, _, _| this);
+
+ let action_example =
+ StatusToast::new("Update ready to install", window, cx, |this, _, cx| {
+ this.action("Restart", cx.listener(|_, _, _, _| {}))
+ });
+
+ let icon_example = StatusToast::new(
+ "Nathan Sobo accepted your contact request",
+ window,
+ cx,
+ |this, _, _| this.icon(ToastIcon::new(IconName::Check).color(Color::Muted)),
+ );
+
+ let success_example = StatusToast::new(
+ "Pushed 4 changes to `zed/main`",
+ window,
+ cx,
+ |this, _, _| this.icon(ToastIcon::new(IconName::Check).color(Color::Success)),
+ );
+
+ let error_example = StatusToast::new(
+ "git push: Couldn't find remote origin `iamnbutler/zed`",
+ window,
+ cx,
+ |this, _, cx| {
+ this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error))
+ .action("More Info", cx.listener(|_, _, _, _| {}))
+ },
+ );
+
+ let warning_example =
+ StatusToast::new("You have outdated settings", window, cx, |this, _, cx| {
+ this.icon(ToastIcon::new(IconName::Warning).color(Color::Warning))
+ .action("More Info", cx.listener(|_, _, _, _| {}))
+ });
+
+ let pr_example = StatusToast::new(
+ "`zed/new-notification-system` created!",
+ window,
+ cx,
+ |this, _, cx| {
+ this.icon(ToastIcon::new(IconName::GitBranchSmall).color(Color::Muted))
+ .action(
+ "Open Pull Request",
+ cx.listener(|_, _, _, cx| cx.open_url("https://github.com/")),
+ )
+ },
+ );
+
+ v_flex()
+ .gap_6()
+ .p_4()
+ .children(vec![
+ example_group_with_title(
+ "Basic Toast",
+ vec![
+ single_example("Text", div().child(text_example).into_any_element()),
+ single_example("Action", div().child(action_example).into_any_element()),
+ single_example("Icon", div().child(icon_example).into_any_element()),
+ ],
+ ),
+ example_group_with_title(
+ "Examples",
+ vec![
+ single_example("Success", div().child(success_example).into_any_element()),
+ single_example("Error", div().child(error_example).into_any_element()),
+ single_example("Warning", div().child(warning_example).into_any_element()),
+ single_example("Create PR", div().child(pr_example).into_any_element()),
+ ],
+ )
+ .vertical(),
+ ])
+ .into_any_element()
+ }
+}
@@ -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::*;
@@ -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,
@@ -14,7 +14,7 @@ pub enum IconButtonShape {
}
#[derive(IntoElement, IntoComponent)]
-#[component(scope = "input")]
+#[component(scope = "Input")]
pub struct IconButton {
base: ButtonLike,
shape: IconButtonShape,
@@ -16,7 +16,7 @@ pub enum ToggleButtonPosition {
}
#[derive(IntoElement, IntoComponent)]
-#[component(scope = "input")]
+#[component(scope = "Input")]
pub struct ToggleButton {
base: ButtonLike,
position_in_group: Option<ToggleButtonPosition>,
@@ -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,
@@ -0,0 +1,3 @@
+mod alert_modal;
+
+pub use alert_modal::*;
@@ -0,0 +1,99 @@
+use crate::prelude::*;
+use gpui::IntoElement;
+use smallvec::{smallvec, SmallVec};
+
+#[derive(IntoElement, IntoComponent)]
+#[component(scope = "Notification")]
+pub struct AlertModal {
+ id: ElementId,
+ children: SmallVec<[AnyElement; 2]>,
+ title: SharedString,
+ primary_action: SharedString,
+ dismiss_label: SharedString,
+}
+
+impl AlertModal {
+ pub fn new(id: impl Into<ElementId>, title: impl Into<SharedString>) -> Self {
+ Self {
+ id: id.into(),
+ children: smallvec![],
+ title: title.into(),
+ primary_action: "Ok".into(),
+ dismiss_label: "Cancel".into(),
+ }
+ }
+
+ pub fn primary_action(mut self, primary_action: impl Into<SharedString>) -> Self {
+ self.primary_action = primary_action.into();
+ self
+ }
+
+ pub fn dismiss_label(mut self, dismiss_label: impl Into<SharedString>) -> Self {
+ self.dismiss_label = dismiss_label.into();
+ self
+ }
+}
+
+impl RenderOnce for AlertModal {
+ fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
+ v_flex()
+ .id(self.id)
+ .elevation_3(cx)
+ .w(px(440.))
+ .p_5()
+ .child(
+ v_flex()
+ .text_ui(cx)
+ .text_color(Color::Muted.color(cx))
+ .gap_1()
+ .child(Headline::new(self.title).size(HeadlineSize::Small))
+ .children(self.children),
+ )
+ .child(
+ h_flex()
+ .h(rems(1.75))
+ .items_center()
+ .child(div().flex_1())
+ .child(
+ h_flex()
+ .items_center()
+ .gap_1()
+ .child(
+ Button::new(self.dismiss_label.clone(), self.dismiss_label.clone())
+ .color(Color::Muted),
+ )
+ .child(Button::new(
+ self.primary_action.clone(),
+ self.primary_action.clone(),
+ )),
+ ),
+ )
+ }
+}
+
+impl ParentElement for AlertModal {
+ fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
+ self.children.extend(elements)
+ }
+}
+
+impl ComponentPreview for AlertModal {
+ fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement {
+ v_flex()
+ .gap_6()
+ .p_4()
+ .children(vec![example_group(
+ vec![
+ single_example(
+ "Basic Alert",
+ AlertModal::new("simple-modal", "Do you want to leave the current call?")
+ .child("The current window will be closed, and connections to any shared projects will be terminated."
+ )
+ .primary_action("Leave Call")
+ .into_any_element(),
+ )
+ ],
+ )])
+ .into_any_element()
+ }
+}
@@ -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,
@@ -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::*;
@@ -1,3 +1,4 @@
+pub mod animation;
mod appearance;
mod color;
mod elevation;
@@ -0,0 +1,276 @@
+use crate::{prelude::*, ContentGroup};
+use gpui::{AnimationElement, AnimationExt, Styled};
+use std::time::Duration;
+
+use gpui::ease_out_quint;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub enum AnimationDuration {
+ Instant = 50,
+ Fast = 150,
+ Slow = 300,
+}
+
+impl AnimationDuration {
+ pub fn duration(&self) -> Duration {
+ Duration::from_millis(*self as u64)
+ }
+}
+
+impl Into<std::time::Duration> for AnimationDuration {
+ fn into(self) -> Duration {
+ self.duration()
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub enum AnimationDirection {
+ FromBottom,
+ FromLeft,
+ FromRight,
+ FromTop,
+}
+
+pub trait DefaultAnimations: Styled + Sized {
+ fn animate_in(
+ self,
+ animation_type: AnimationDirection,
+ fade_in: bool,
+ ) -> AnimationElement<Self> {
+ let animation_name = match animation_type {
+ AnimationDirection::FromBottom => "animate_from_bottom",
+ AnimationDirection::FromLeft => "animate_from_left",
+ AnimationDirection::FromRight => "animate_from_right",
+ AnimationDirection::FromTop => "animate_from_top",
+ };
+
+ self.with_animation(
+ animation_name,
+ gpui::Animation::new(AnimationDuration::Fast.into()).with_easing(ease_out_quint()),
+ move |mut this, delta| {
+ let start_opacity = 0.4;
+ let start_pos = 0.0;
+ let end_pos = 40.0;
+
+ if fade_in {
+ this = this.opacity(start_opacity + delta * (1.0 - start_opacity));
+ }
+
+ match animation_type {
+ AnimationDirection::FromBottom => {
+ this.bottom(px(start_pos + delta * (end_pos - start_pos)))
+ }
+ AnimationDirection::FromLeft => {
+ this.left(px(start_pos + delta * (end_pos - start_pos)))
+ }
+ AnimationDirection::FromRight => {
+ this.right(px(start_pos + delta * (end_pos - start_pos)))
+ }
+ AnimationDirection::FromTop => {
+ this.top(px(start_pos + delta * (end_pos - start_pos)))
+ }
+ }
+ },
+ )
+ }
+
+ fn animate_in_from_bottom(self, fade: bool) -> AnimationElement<Self> {
+ self.animate_in(AnimationDirection::FromBottom, fade)
+ }
+
+ fn animate_in_from_left(self, fade: bool) -> AnimationElement<Self> {
+ self.animate_in(AnimationDirection::FromLeft, fade)
+ }
+
+ fn animate_in_from_right(self, fade: bool) -> AnimationElement<Self> {
+ self.animate_in(AnimationDirection::FromRight, fade)
+ }
+
+ fn animate_in_from_top(self, fade: bool) -> AnimationElement<Self> {
+ self.animate_in(AnimationDirection::FromTop, fade)
+ }
+}
+
+impl<E: Styled> DefaultAnimations for E {}
+
+// Don't use this directly, it only exists to show animation previews
+#[derive(IntoComponent)]
+struct Animation {}
+
+// View this component preview using `workspace: open component-preview`
+impl ComponentPreview for Animation {
+ fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement {
+ let container_size = 128.0;
+ let element_size = 32.0;
+ let left_offset = element_size - container_size / 2.0;
+ v_flex()
+ .gap_6()
+ .children(vec![
+ example_group_with_title(
+ "Animate In",
+ vec![
+ single_example(
+ "From Bottom",
+ ContentGroup::new()
+ .relative()
+ .items_center()
+ .justify_center()
+ .size(px(container_size))
+ .child(
+ div()
+ .id("animate-in-from-bottom")
+ .absolute()
+ .size(px(element_size))
+ .left(px(left_offset))
+ .rounded_md()
+ .bg(gpui::red())
+ .animate_in(AnimationDirection::FromBottom, false),
+ )
+ .into_any_element(),
+ ),
+ single_example(
+ "From Top",
+ ContentGroup::new()
+ .relative()
+ .items_center()
+ .justify_center()
+ .size(px(container_size))
+ .child(
+ div()
+ .id("animate-in-from-top")
+ .absolute()
+ .size(px(element_size))
+ .left(px(left_offset))
+ .rounded_md()
+ .bg(gpui::blue())
+ .animate_in(AnimationDirection::FromTop, false),
+ )
+ .into_any_element(),
+ ),
+ single_example(
+ "From Left",
+ ContentGroup::new()
+ .relative()
+ .items_center()
+ .justify_center()
+ .size(px(container_size))
+ .child(
+ div()
+ .id("animate-in-from-left")
+ .absolute()
+ .size(px(element_size))
+ .left(px(left_offset))
+ .rounded_md()
+ .bg(gpui::green())
+ .animate_in(AnimationDirection::FromLeft, false),
+ )
+ .into_any_element(),
+ ),
+ single_example(
+ "From Right",
+ ContentGroup::new()
+ .relative()
+ .items_center()
+ .justify_center()
+ .size(px(container_size))
+ .child(
+ div()
+ .id("animate-in-from-right")
+ .absolute()
+ .size(px(element_size))
+ .left(px(left_offset))
+ .rounded_md()
+ .bg(gpui::yellow())
+ .animate_in(AnimationDirection::FromRight, false),
+ )
+ .into_any_element(),
+ ),
+ ],
+ )
+ .grow(),
+ example_group_with_title(
+ "Fade and Animate In",
+ vec![
+ single_example(
+ "From Bottom",
+ ContentGroup::new()
+ .relative()
+ .items_center()
+ .justify_center()
+ .size(px(container_size))
+ .child(
+ div()
+ .id("fade-animate-in-from-bottom")
+ .absolute()
+ .size(px(element_size))
+ .left(px(left_offset))
+ .rounded_md()
+ .bg(gpui::red())
+ .animate_in(AnimationDirection::FromBottom, true),
+ )
+ .into_any_element(),
+ ),
+ single_example(
+ "From Top",
+ ContentGroup::new()
+ .relative()
+ .items_center()
+ .justify_center()
+ .size(px(container_size))
+ .child(
+ div()
+ .id("fade-animate-in-from-top")
+ .absolute()
+ .size(px(element_size))
+ .left(px(left_offset))
+ .rounded_md()
+ .bg(gpui::blue())
+ .animate_in(AnimationDirection::FromTop, true),
+ )
+ .into_any_element(),
+ ),
+ single_example(
+ "From Left",
+ ContentGroup::new()
+ .relative()
+ .items_center()
+ .justify_center()
+ .size(px(container_size))
+ .child(
+ div()
+ .id("fade-animate-in-from-left")
+ .absolute()
+ .size(px(element_size))
+ .left(px(left_offset))
+ .rounded_md()
+ .bg(gpui::green())
+ .animate_in(AnimationDirection::FromLeft, true),
+ )
+ .into_any_element(),
+ ),
+ single_example(
+ "From Right",
+ ContentGroup::new()
+ .relative()
+ .items_center()
+ .justify_center()
+ .size(px(container_size))
+ .child(
+ div()
+ .id("fade-animate-in-from-right")
+ .absolute()
+ .size(px(element_size))
+ .left(px(left_offset))
+ .rounded_md()
+ .bg(gpui::yellow())
+ .animate_in(AnimationDirection::FromRight, true),
+ )
+ .into_any_element(),
+ ),
+ ],
+ )
+ .grow(),
+ ])
+ .into_any_element()
+ }
+}
@@ -33,14 +33,15 @@ pub fn derive_into_component(input: TokenStream) -> TokenStream {
let name = &input.ident;
let scope_impl = if let Some(s) = scope_val {
+ let scope_str = s.clone();
quote! {
- fn scope() -> Option<&'static str> {
- Some(#s)
+ fn scope() -> Option<component::ComponentScope> {
+ Some(component::ComponentScope::from(#scope_str))
}
}
} else {
quote! {
- fn scope() -> Option<&'static str> {
+ fn scope() -> Option<component::ComponentScope> {
None
}
}
@@ -0,0 +1,216 @@
+use std::time::{Duration, Instant};
+
+use gpui::{AnyView, DismissEvent, Entity, FocusHandle, ManagedView, Subscription, Task};
+use ui::{animation::DefaultAnimations, prelude::*};
+
+const DEFAULT_TOAST_DURATION: Duration = Duration::from_millis(2400);
+const MINIMUM_RESUME_DURATION: Duration = Duration::from_millis(800);
+
+pub trait ToastView: ManagedView {}
+
+trait ToastViewHandle {
+ fn view(&self) -> AnyView;
+}
+
+impl<V: ToastView> ToastViewHandle for Entity<V> {
+ fn view(&self) -> AnyView {
+ self.clone().into()
+ }
+}
+
+pub struct ActiveToast {
+ toast: Box<dyn ToastViewHandle>,
+ _subscriptions: [Subscription; 1],
+ focus_handle: FocusHandle,
+}
+
+struct DismissTimer {
+ instant_started: Instant,
+ _task: Task<()>,
+}
+
+pub struct ToastLayer {
+ active_toast: Option<ActiveToast>,
+ duration_remaining: Option<Duration>,
+ dismiss_timer: Option<DismissTimer>,
+}
+
+impl Default for ToastLayer {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl ToastLayer {
+ pub fn new() -> Self {
+ Self {
+ active_toast: None,
+ duration_remaining: None,
+ dismiss_timer: None,
+ }
+ }
+
+ pub fn toggle_toast<V>(
+ &mut self,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ new_toast: Entity<V>,
+ ) where
+ V: ToastView,
+ {
+ if let Some(active_toast) = &self.active_toast {
+ let is_close = active_toast.toast.view().downcast::<V>().is_ok();
+ let did_close = self.hide_toast(window, cx);
+ if is_close || !did_close {
+ return;
+ }
+ }
+ self.show_toast(new_toast, window, cx);
+ }
+
+ pub fn show_toast<V>(
+ &mut self,
+ new_toast: Entity<V>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) where
+ V: ToastView,
+ {
+ let focus_handle = cx.focus_handle();
+
+ self.active_toast = Some(ActiveToast {
+ toast: Box::new(new_toast.clone()),
+ _subscriptions: [cx.subscribe_in(
+ &new_toast,
+ window,
+ |this, _, _: &DismissEvent, window, cx| {
+ this.hide_toast(window, cx);
+ },
+ )],
+ focus_handle,
+ });
+
+ self.start_dismiss_timer(DEFAULT_TOAST_DURATION, window, cx);
+
+ cx.notify();
+ }
+
+ pub fn hide_toast(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> bool {
+ cx.notify();
+
+ true
+ }
+
+ pub fn active_toast<V>(&self) -> Option<Entity<V>>
+ where
+ V: 'static,
+ {
+ let active_toast = self.active_toast.as_ref()?;
+ active_toast.toast.view().downcast::<V>().ok()
+ }
+
+ pub fn has_active_toast(&self) -> bool {
+ self.active_toast.is_some()
+ }
+
+ fn pause_dismiss_timer(&mut self) {
+ let Some(dismiss_timer) = self.dismiss_timer.take() else {
+ return;
+ };
+ let Some(duration_remaining) = self.duration_remaining.as_mut() else {
+ return;
+ };
+ *duration_remaining =
+ duration_remaining.saturating_sub(dismiss_timer.instant_started.elapsed());
+ if *duration_remaining < MINIMUM_RESUME_DURATION {
+ *duration_remaining = MINIMUM_RESUME_DURATION;
+ }
+ }
+
+ /// Starts a timer to automatically dismiss the toast after the specified duration
+ pub fn start_dismiss_timer(
+ &mut self,
+ duration: Duration,
+ _window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ self.clear_dismiss_timer(cx);
+
+ let instant_started = std::time::Instant::now();
+ let task = cx.spawn(|this, mut cx| async move {
+ cx.background_executor().timer(duration).await;
+
+ if let Some(this) = this.upgrade() {
+ this.update(&mut cx, |this, cx| {
+ this.active_toast.take();
+ cx.notify();
+ })
+ .ok();
+ }
+ });
+
+ self.duration_remaining = Some(duration);
+ self.dismiss_timer = Some(DismissTimer {
+ instant_started,
+ _task: task,
+ });
+ cx.notify();
+ }
+
+ /// Restarts the dismiss timer with a new duration
+ pub fn restart_dismiss_timer(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+ let Some(duration) = self.duration_remaining else {
+ return;
+ };
+ self.start_dismiss_timer(duration, window, cx);
+ cx.notify();
+ }
+
+ /// Clears the dismiss timer if one exists
+ pub fn clear_dismiss_timer(&mut self, cx: &mut Context<Self>) {
+ self.dismiss_timer.take();
+ cx.notify();
+ }
+}
+
+impl Render for ToastLayer {
+ fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+ let Some(active_toast) = &self.active_toast else {
+ return div();
+ };
+ let handle = cx.weak_entity();
+
+ div().absolute().size_full().bottom_0().left_0().child(
+ v_flex()
+ .id("toast-layer-container")
+ .absolute()
+ .w_full()
+ .bottom(px(0.))
+ .flex()
+ .flex_col()
+ .items_center()
+ .track_focus(&active_toast.focus_handle)
+ .child(
+ h_flex()
+ .id("active-toast-container")
+ .occlude()
+ .on_hover(move |hover_start, window, cx| {
+ let Some(this) = handle.upgrade() else {
+ return;
+ };
+ if *hover_start {
+ this.update(cx, |this, _| this.pause_dismiss_timer());
+ } else {
+ this.update(cx, |this, cx| this.restart_dismiss_timer(window, cx));
+ }
+ cx.stop_propagation();
+ })
+ .on_click(|_, _, cx| {
+ cx.stop_propagation();
+ })
+ .child(active_toast.toast.view()),
+ )
+ .animate_in(AnimationDirection::FromBottom, true),
+ )
+ }
+}
@@ -10,9 +10,12 @@ pub mod shared_screen;
mod status_bar;
pub mod tasks;
mod theme_preview;
+mod toast_layer;
mod toolbar;
mod workspace_settings;
+pub use toast_layer::{ToastLayer, ToastView};
+
use anyhow::{anyhow, Context as _, Result};
use call::{call_settings::CallSettings, ActiveCall};
use client::{
@@ -816,6 +819,7 @@ pub struct Workspace {
last_active_view_id: Option<proto::ViewId>,
status_bar: Entity<StatusBar>,
modal_layer: Entity<ModalLayer>,
+ toast_layer: Entity<ToastLayer>,
titlebar_item: Option<AnyView>,
notifications: Notifications,
project: Entity<Project>,
@@ -1032,6 +1036,7 @@ impl Workspace {
});
let modal_layer = cx.new(|_| ModalLayer::new());
+ let toast_layer = cx.new(|_| ToastLayer::new());
let session_id = app_state.session.read(cx).id().to_owned();
@@ -1112,6 +1117,7 @@ impl Workspace {
last_active_view_id: None,
status_bar,
modal_layer,
+ toast_layer,
titlebar_item: None,
notifications: Default::default(),
left_dock,
@@ -4971,6 +4977,17 @@ impl Workspace {
})
}
+ pub fn toggle_status_toast<V: ToastView>(
+ &mut self,
+ window: &mut Window,
+ cx: &mut App,
+ entity: Entity<V>,
+ ) {
+ self.toast_layer.update(cx, |toast_layer, cx| {
+ toast_layer.toggle_toast(window, cx, entity)
+ })
+ }
+
pub fn toggle_centered_layout(
&mut self,
_: &ToggleCenteredLayout,
@@ -5485,7 +5502,8 @@ impl Render for Workspace {
.children(self.render_notifications(window, cx)),
)
.child(self.status_bar.clone())
- .child(self.modal_layer.clone()),
+ .child(self.modal_layer.clone())
+ .child(self.toast_layer.clone()),
),
window,
cx,
@@ -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| {