From f6fbf662b4da903bcf56112cb5dc565b51a56309 Mon Sep 17 00:00:00 2001 From: Nate Butler Date: Wed, 6 Nov 2024 16:54:18 -0500 Subject: [PATCH] Add `ui::ComponentPreview` (#20319) The `ComponentPreview` trait enables rendering storybook-like previews of components inside of Zed. ![CleanShot 2024-11-06 at 16 32 25@2x](https://github.com/user-attachments/assets/6894663f-1bbc-4a40-b420-33882e9e239a) This initial version will work for any component that doesn't return a view. Example impl: ```rust impl ComponentPreview for Checkbox { fn description() -> impl Into> { "A checkbox lets people choose between opposing..." } fn examples() -> Vec> { vec![ example_group( "Default", vec![ single_example( "Unselected", Checkbox::new("checkbox_unselected", Selection::Unselected), ), // ... more examples ], ), // ... more examples ] } } ``` Example usage: ```rust fn render_components_page(&self, cx: &ViewContext) -> impl IntoElement { v_flex() .gap_2() .child(Checkbox::render_component_previews(cx)) .child(Icon::render_component_previews(cx)) } } ``` Release Notes: - N/A --- crates/ui/src/components/checkbox.rs | 48 ++++++++ crates/ui/src/components/icon.rs | 29 ++++- crates/ui/src/prelude.rs | 1 + crates/ui/src/traits.rs | 1 + crates/ui/src/traits/component_preview.rs | 131 ++++++++++++++++++++++ crates/workspace/src/theme_preview.rs | 65 ++++++----- 6 files changed, 249 insertions(+), 26 deletions(-) create mode 100644 crates/ui/src/traits/component_preview.rs diff --git a/crates/ui/src/components/checkbox.rs b/crates/ui/src/components/checkbox.rs index d3c4d377ae26e4245d39fcdae53f18f2728a755c..64fb709fc3b5aeed1b47366a97fea148d401e7d6 100644 --- a/crates/ui/src/components/checkbox.rs +++ b/crates/ui/src/components/checkbox.rs @@ -115,3 +115,51 @@ impl RenderOnce for Checkbox { ) } } + +impl ComponentPreview for Checkbox { + fn description() -> impl Into> { + "A checkbox lets people choose between a pair of opposing states, like enabled and disabled, using a different appearance to indicate each state." + } + + fn examples() -> Vec> { + vec![ + example_group( + "Default", + vec![ + single_example( + "Unselected", + Checkbox::new("checkbox_unselected", Selection::Unselected), + ), + single_example( + "Indeterminate", + Checkbox::new("checkbox_indeterminate", Selection::Indeterminate), + ), + single_example( + "Selected", + Checkbox::new("checkbox_selected", Selection::Selected), + ), + ], + ), + example_group( + "Disabled", + vec![ + single_example( + "Unselected", + Checkbox::new("checkbox_disabled_unselected", Selection::Unselected) + .disabled(true), + ), + single_example( + "Indeterminate", + Checkbox::new("checkbox_disabled_indeterminate", Selection::Indeterminate) + .disabled(true), + ), + single_example( + "Selected", + Checkbox::new("checkbox_disabled_selected", Selection::Selected) + .disabled(true), + ), + ], + ), + ] + } +} diff --git a/crates/ui/src/components/icon.rs b/crates/ui/src/components/icon.rs index 3aa3dc7615e462d5be775a7cd77c05e9b0bbd214..ddc36b11709e9e30a26400fd8e5b7109a58cc4b0 100644 --- a/crates/ui/src/components/icon.rs +++ b/crates/ui/src/components/icon.rs @@ -4,7 +4,11 @@ use serde::{Deserialize, Serialize}; use strum::{EnumIter, EnumString, IntoStaticStr}; use ui_macros::DerivePathStr; -use crate::{prelude::*, Indicator}; +use crate::{ + prelude::*, + traits::component_preview::{example_group, ComponentExample, ComponentPreview}, + Indicator, +}; #[derive(IntoElement)] pub enum AnyIcon { @@ -494,3 +498,26 @@ impl RenderOnce for IconWithIndicator { }) } } + +impl ComponentPreview for Icon { + fn examples() -> Vec> { + let arrow_icons = vec![ + IconName::ArrowDown, + IconName::ArrowLeft, + IconName::ArrowRight, + IconName::ArrowUp, + IconName::ArrowCircle, + ]; + + vec![example_group( + "Arrow Icons", + arrow_icons + .into_iter() + .map(|icon| { + let name = format!("{:?}", icon).to_string(); + ComponentExample::new(name, Icon::new(icon)) + }) + .collect(), + )] + } +} diff --git a/crates/ui/src/prelude.rs b/crates/ui/src/prelude.rs index d01ac11bc3e43c937830034df73e21ffe48ff944..8801efed5a18e99ae2446cb22757c5fed7a79a17 100644 --- a/crates/ui/src/prelude.rs +++ b/crates/ui/src/prelude.rs @@ -9,6 +9,7 @@ pub use gpui::{ pub use crate::styles::{rems_from_px, vh, vw, PlatformStyle, StyledTypography, TextSize}; pub use crate::traits::clickable::*; +pub use crate::traits::component_preview::*; pub use crate::traits::disableable::*; pub use crate::traits::fixed::*; pub use crate::traits::selectable::*; diff --git a/crates/ui/src/traits.rs b/crates/ui/src/traits.rs index 7e52f2a86756470646df6605ee034281098c9d8b..0898375e969f98b931b30189edb1e39d84ca57f9 100644 --- a/crates/ui/src/traits.rs +++ b/crates/ui/src/traits.rs @@ -1,4 +1,5 @@ pub mod clickable; +pub mod component_preview; pub mod disableable; pub mod fixed; pub mod selectable; diff --git a/crates/ui/src/traits/component_preview.rs b/crates/ui/src/traits/component_preview.rs new file mode 100644 index 0000000000000000000000000000000000000000..d767b734b1531a955fbb97b507fe785c7fa9b860 --- /dev/null +++ b/crates/ui/src/traits/component_preview.rs @@ -0,0 +1,131 @@ +#![allow(missing_docs)] +use crate::prelude::*; +use gpui::{AnyElement, SharedString}; + +/// Implement this trait to enable rich UI previews with metadata in the Theme Preview tool. +pub trait ComponentPreview: IntoElement { + fn title() -> &'static str { + std::any::type_name::() + } + + fn description() -> impl Into> { + None + } + + fn examples() -> Vec>; + + fn component_previews() -> Vec { + Self::examples() + .into_iter() + .map(|example| Self::render_example_group(example)) + .collect() + } + + fn render_component_previews(cx: &WindowContext) -> AnyElement { + let title = Self::title(); + let (source, title) = title + .rsplit_once("::") + .map_or((None, title), |(s, t)| (Some(s), t)); + let description = Self::description().into(); + + v_flex() + .gap_3() + .p_4() + .border_1() + .border_color(cx.theme().colors().border) + .rounded_md() + .child( + v_flex() + .gap_1() + .child( + h_flex() + .gap_1() + .child(Headline::new(title).size(HeadlineSize::Small)) + .when_some(source, |this, source| { + this.child(Label::new(format!("({})", source)).color(Color::Muted)) + }), + ) + .when_some(description, |this, description| { + this.child( + div() + .text_ui_sm(cx) + .text_color(cx.theme().colors().text_muted) + .max_w(px(600.0)) + .child(description), + ) + }), + ) + .children(Self::component_previews()) + .into_any_element() + } + + fn render_example_group(group: ComponentExampleGroup) -> AnyElement { + v_flex() + .gap_2() + .child(Label::new(group.title).size(LabelSize::Small)) + .child( + h_flex() + .gap_6() + .children(group.examples.into_iter().map(Self::render_example)) + .into_any_element(), + ) + .into_any_element() + } + + fn render_example(example: ComponentExample) -> AnyElement { + v_flex() + .gap_1() + .child(example.element) + .child( + Label::new(example.variant_name) + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + .into_any_element() + } +} + +/// A single example of a component. +pub struct ComponentExample { + variant_name: SharedString, + element: T, +} + +impl ComponentExample { + /// Create a new example with the given variant name and example value. + pub fn new(variant_name: impl Into, example: T) -> Self { + Self { + variant_name: variant_name.into(), + element: example, + } + } +} + +/// A group of component examples. +pub struct ComponentExampleGroup { + pub title: SharedString, + pub examples: Vec>, +} + +impl ComponentExampleGroup { + /// Create a new group of examples with the given title. + pub fn new(title: impl Into, examples: Vec>) -> Self { + Self { + title: title.into(), + examples, + } + } +} + +/// Create a single example +pub fn single_example(variant_name: impl Into, example: T) -> ComponentExample { + ComponentExample::new(variant_name, example) +} + +/// Create a group of examples +pub fn example_group( + title: impl Into, + examples: Vec>, +) -> ComponentExampleGroup { + ComponentExampleGroup::new(title, examples) +} diff --git a/crates/workspace/src/theme_preview.rs b/crates/workspace/src/theme_preview.rs index e65264137216239a7af7800db5fdb275b6d607c5..f75e07180267106e8face86d1ef8b3d80c7b688c 100644 --- a/crates/workspace/src/theme_preview.rs +++ b/crates/workspace/src/theme_preview.rs @@ -4,8 +4,8 @@ use strum::IntoEnumIterator; use theme::all_theme_colors; use ui::{ prelude::*, utils::calculate_contrast_ratio, AudioStatus, Availability, Avatar, - AvatarAudioStatusIndicator, AvatarAvailabilityIndicator, ButtonLike, ElevationIndex, Facepile, - TintColor, Tooltip, + AvatarAudioStatusIndicator, AvatarAvailabilityIndicator, ButtonLike, Checkbox, ElevationIndex, + Facepile, TintColor, Tooltip, }; use crate::{Item, Workspace}; @@ -26,6 +26,7 @@ pub fn init(cx: &mut AppContext) { enum ThemePreviewPage { Overview, Typography, + Components, } impl ThemePreviewPage { @@ -33,6 +34,7 @@ impl ThemePreviewPage { match self { Self::Overview => "Overview", Self::Typography => "Typography", + Self::Components => "Components", } } } @@ -58,6 +60,7 @@ impl ThemePreview { match page { ThemePreviewPage::Overview => self.render_overview_page(cx).into_any_element(), ThemePreviewPage::Typography => self.render_typography_page(cx).into_any_element(), + ThemePreviewPage::Components => self.render_components_page(cx).into_any_element(), } } } @@ -456,8 +459,6 @@ impl ThemePreview { .text_color(cx.theme().colors().text) .gap_2() .child(Headline::new(layer.clone().to_string()).size(HeadlineSize::Medium)) - .child(self.render_avatars(cx)) - .child(self.render_buttons(layer, cx)) .child(self.render_text(layer, cx)) .child(self.render_colors(layer, cx)) } @@ -499,39 +500,53 @@ impl ThemePreview { .child(Label::new("Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.")) ) } + + fn render_components_page(&self, cx: &ViewContext) -> impl IntoElement { + let layer = ElevationIndex::Surface; + + v_flex() + .id("theme-preview-components") + .overflow_scroll() + .size_full() + .gap_2() + .child(Checkbox::render_component_previews(cx)) + .child(Icon::render_component_previews(cx)) + .child(self.render_avatars(cx)) + .child(self.render_buttons(layer, cx)) + } + + fn render_page_nav(&self, cx: &ViewContext) -> impl IntoElement { + h_flex() + .id("theme-preview-nav") + .items_center() + .gap_4() + .py_2() + .bg(Self::preview_bg(cx)) + .children(ThemePreviewPage::iter().map(|p| { + Button::new(ElementId::Name(p.name().into()), p.name()) + .on_click(cx.listener(move |this, _, cx| { + this.current_page = p; + cx.notify(); + })) + .selected(p == self.current_page) + .selected_style(ButtonStyle::Tinted(TintColor::Accent)) + })) + } } impl Render for ThemePreview { fn render(&mut self, cx: &mut ViewContext) -> impl ui::IntoElement { - h_flex() + v_flex() .id("theme-preview") .key_context("ThemePreview") .items_start() .overflow_hidden() .size_full() .max_h_full() - .p_4() .track_focus(&self.focus_handle) + .px_2() .bg(Self::preview_bg(cx)) - .gap_4() - .child( - v_flex() - .items_start() - .gap_1() - .w(px(240.)) - .child( - v_flex() - .gap_px() - .children(ThemePreviewPage::iter().map(|p| { - Button::new(ElementId::Name(p.name().into()), p.name()) - .on_click(cx.listener(move |this, _, cx| { - this.current_page = p; - cx.notify(); - })) - .selected(p == self.current_page) - })), - ), - ) + .child(self.render_page_nav(cx)) .child(self.view(self.current_page, cx)) } }