Begin setting up stories

Marshall Bowers created

Change summary

Cargo.lock                                        |   1 
crates/gpui3/src/elements/text.rs                 |  12 +
crates/storybook2/Cargo.toml                      |   1 
crates/storybook2/src/stories.rs                  |   3 
crates/storybook2/src/stories/components.rs       |   1 
crates/storybook2/src/stories/components/panel.rs |  35 +++
crates/storybook2/src/stories/elements.rs         |   1 
crates/storybook2/src/stories/elements/label.rs   |  28 ++
crates/storybook2/src/stories/kitchen_sink.rs     |  36 +++
crates/storybook2/src/story.rs                    |  52 +++++
crates/storybook2/src/story_selector.rs           | 116 +++++++++++
crates/storybook2/src/storybook2.rs               |   3 
crates/storybook2/src/ui.rs                       |   2 
crates/storybook2/src/ui/elements.rs              |   2 
crates/storybook2/src/ui/elements/label.rs        | 165 +++++++++++++++++
crates/storybook2/src/ui/prelude.rs               |   4 
crates/storybook2/src/ui/theme.rs                 |  10 +
17 files changed, 471 insertions(+), 1 deletion(-)

Detailed changes

Cargo.lock 🔗

@@ -7606,6 +7606,7 @@ dependencies = [
  "serde",
  "settings",
  "simplelog",
+ "smallvec",
  "strum",
  "theme",
  "util",

crates/gpui3/src/elements/text.rs 🔗

@@ -25,6 +25,18 @@ impl<V: 'static> IntoAnyElement<V> for &'static str {
     }
 }
 
+// TODO: Figure out how to pass `String` to `child` without this.
+// This impl doesn't exist in the `gpui2` crate.
+impl<S: 'static> IntoAnyElement<S> for String {
+    fn into_any(self) -> AnyElement<S> {
+        Text {
+            text: ArcCow::from(self),
+            state_type: PhantomData,
+        }
+        .into_any()
+    }
+}
+
 pub struct Text<S> {
     text: ArcCow<'static, str>,
     state_type: PhantomData<S>,

crates/storybook2/Cargo.toml 🔗

@@ -20,6 +20,7 @@ rust-embed.workspace = true
 serde.workspace = true
 settings = { path = "../settings" }
 simplelog = "0.9"
+smallvec.workspace = true
 strum = { version = "0.25.0", features = ["derive"] }
 theme = { path = "../theme" }
 util = { path = "../util" }

crates/storybook2/src/stories/components/panel.rs 🔗

@@ -0,0 +1,35 @@
+use std::marker::PhantomData;
+
+use crate::ui::prelude::*;
+use crate::ui::{Label, Panel};
+
+use crate::story::Story;
+
+#[derive(Element)]
+pub struct PanelStory<S: 'static + Send + Sync> {
+    state_type: PhantomData<S>,
+}
+
+impl<S: 'static + Send + Sync> PanelStory<S> {
+    pub fn new() -> Self {
+        Self {
+            state_type: PhantomData,
+        }
+    }
+
+    fn render(&mut self, cx: &mut ViewContext<S>) -> impl Element<State = S> {
+        Story::container(cx)
+            .child(Story::title_for::<_, Panel<S>>(cx))
+            .child(Story::label(cx, "Default"))
+            .child(Panel::new(
+                ScrollState::default(),
+                |_, _| {
+                    vec![div()
+                        .overflow_y_scroll(ScrollState::default())
+                        .children((0..100).map(|ix| Label::new(format!("Item {}", ix + 1))))
+                        .into_any()]
+                },
+                Box::new(()),
+            ))
+    }
+}

crates/storybook2/src/stories/elements/label.rs 🔗

@@ -0,0 +1,28 @@
+use std::marker::PhantomData;
+
+use crate::ui::prelude::*;
+use crate::ui::Label;
+
+use crate::story::Story;
+
+#[derive(Element)]
+pub struct LabelStory<S: 'static + Send + Sync> {
+    state_type: PhantomData<S>,
+}
+
+impl<S: 'static + Send + Sync> LabelStory<S> {
+    pub fn new() -> Self {
+        Self {
+            state_type: PhantomData,
+        }
+    }
+
+    fn render(&mut self, cx: &mut ViewContext<S>) -> impl Element<State = S> {
+        Story::container(cx)
+            .child(Story::title_for::<_, Label<S>>(cx))
+            .child(Story::label(cx, "Default"))
+            .child(Label::new("Hello, world!"))
+            .child(Story::label(cx, "Highlighted"))
+            .child(Label::new("Hello, world!").with_highlights(vec![0, 1, 2, 7, 8, 12]))
+    }
+}

crates/storybook2/src/stories/kitchen_sink.rs 🔗

@@ -0,0 +1,36 @@
+use std::marker::PhantomData;
+
+use strum::IntoEnumIterator;
+
+use crate::story::Story;
+use crate::story_selector::{ComponentStory, ElementStory};
+use crate::ui::prelude::*;
+
+#[derive(Element)]
+pub struct KitchenSinkStory<S: 'static + Send + Sync> {
+    state_type: PhantomData<S>,
+}
+
+impl<S: 'static + Send + Sync> KitchenSinkStory<S> {
+    pub fn new() -> Self {
+        Self {
+            state_type: PhantomData,
+        }
+    }
+
+    fn render(&mut self, cx: &mut ViewContext<S>) -> impl Element<State = S> {
+        let element_stories = ElementStory::iter().map(|selector| selector.story());
+        let component_stories = ComponentStory::iter().map(|selector| selector.story());
+
+        Story::container(cx)
+            .overflow_y_scroll(ScrollState::default())
+            .child(Story::title(cx, "Kitchen Sink"))
+            .child(Story::label(cx, "Elements"))
+            .child(div().flex().flex_col().children_any(element_stories))
+            .child(Story::label(cx, "Components"))
+            .child(div().flex().flex_col().children_any(component_stories))
+            // Add a bit of space at the bottom of the kitchen sink so elements
+            // don't end up squished right up against the bottom of the screen.
+            .child(div().p_4())
+    }
+}

crates/storybook2/src/story.rs 🔗

@@ -0,0 +1,52 @@
+use crate::theme::theme;
+use crate::ui::prelude::*;
+use gpui3::Div;
+
+pub struct Story {}
+
+impl Story {
+    pub fn container<S: 'static + Send + Sync>(cx: &mut ViewContext<S>) -> Div<S> {
+        let theme = theme(cx);
+
+        div()
+            .size_full()
+            .flex()
+            .flex_col()
+            .pt_2()
+            .px_4()
+            .font("Zed Mono Extended")
+            .fill(theme.lowest.base.default.background)
+    }
+
+    pub fn title<S: 'static + Send + Sync>(
+        cx: &mut ViewContext<S>,
+        title: &str,
+    ) -> impl Element<State = S> {
+        let theme = theme(cx);
+
+        div()
+            .text_xl()
+            .text_color(theme.lowest.base.default.foreground)
+            .child(title.to_owned())
+    }
+
+    pub fn title_for<S: 'static + Send + Sync, T>(
+        cx: &mut ViewContext<S>,
+    ) -> impl Element<State = S> {
+        Self::title(cx, std::any::type_name::<T>())
+    }
+
+    pub fn label<S: 'static + Send + Sync>(
+        cx: &mut ViewContext<S>,
+        label: &str,
+    ) -> impl Element<State = S> {
+        let theme = theme(cx);
+
+        div()
+            .mt_4()
+            .mb_2()
+            .text_xs()
+            .text_color(theme.lowest.base.default.foreground)
+            .child(label.to_owned())
+    }
+}

crates/storybook2/src/story_selector.rs 🔗

@@ -0,0 +1,116 @@
+use std::str::FromStr;
+use std::sync::OnceLock;
+
+use anyhow::{anyhow, Context};
+use clap::builder::PossibleValue;
+use clap::ValueEnum;
+use gpui3::AnyElement;
+use strum::{EnumIter, EnumString, IntoEnumIterator};
+
+use crate::ui::prelude::*;
+
+#[derive(Debug, PartialEq, Eq, Clone, Copy, strum::Display, EnumString, EnumIter)]
+#[strum(serialize_all = "snake_case")]
+pub enum ElementStory {
+    Label,
+}
+
+impl ElementStory {
+    pub fn story<S: 'static + Send + Sync>(&self) -> AnyElement<S> {
+        use crate::stories::elements;
+
+        match self {
+            Self::Label => elements::label::LabelStory::new().into_any(),
+        }
+    }
+}
+
+#[derive(Debug, PartialEq, Eq, Clone, Copy, strum::Display, EnumString, EnumIter)]
+#[strum(serialize_all = "snake_case")]
+pub enum ComponentStory {
+    Panel,
+}
+
+impl ComponentStory {
+    pub fn story<S: 'static + Send + Sync>(&self) -> AnyElement<S> {
+        use crate::stories::components;
+
+        match self {
+            Self::Panel => components::panel::PanelStory::new().into_any(),
+        }
+    }
+}
+
+#[derive(Debug, PartialEq, Eq, Clone, Copy)]
+pub enum StorySelector {
+    Element(ElementStory),
+    Component(ComponentStory),
+    KitchenSink,
+}
+
+impl FromStr for StorySelector {
+    type Err = anyhow::Error;
+
+    fn from_str(raw_story_name: &str) -> std::result::Result<Self, Self::Err> {
+        let story = raw_story_name.to_ascii_lowercase();
+
+        if story == "kitchen_sink" {
+            return Ok(Self::KitchenSink);
+        }
+
+        if let Some((_, story)) = story.split_once("elements/") {
+            let element_story = ElementStory::from_str(story)
+                .with_context(|| format!("story not found for element '{story}'"))?;
+
+            return Ok(Self::Element(element_story));
+        }
+
+        if let Some((_, story)) = story.split_once("components/") {
+            let component_story = ComponentStory::from_str(story)
+                .with_context(|| format!("story not found for component '{story}'"))?;
+
+            return Ok(Self::Component(component_story));
+        }
+
+        Err(anyhow!("story not found for '{raw_story_name}'"))
+    }
+}
+
+impl StorySelector {
+    pub fn story<S: 'static + Send + Sync>(&self) -> AnyElement<S> {
+        match self {
+            Self::Element(element_story) => element_story.story(),
+            Self::Component(component_story) => component_story.story(),
+            Self::KitchenSink => crate::stories::kitchen_sink::KitchenSinkStory::new().into_any(),
+        }
+    }
+}
+
+/// The list of all stories available in the storybook.
+static ALL_STORY_SELECTORS: OnceLock<Vec<StorySelector>> = OnceLock::new();
+
+impl ValueEnum for StorySelector {
+    fn value_variants<'a>() -> &'a [Self] {
+        let stories = ALL_STORY_SELECTORS.get_or_init(|| {
+            let element_stories = ElementStory::iter().map(StorySelector::Element);
+            let component_stories = ComponentStory::iter().map(StorySelector::Component);
+
+            element_stories
+                .chain(component_stories)
+                .chain(std::iter::once(StorySelector::KitchenSink))
+                .collect::<Vec<_>>()
+        });
+
+        stories
+    }
+
+    fn to_possible_value(&self) -> Option<clap::builder::PossibleValue> {
+        let value = match self {
+            Self::Element(story) => format!("elements/{story}"),
+            Self::Component(story) => format!("components/{story}"),
+            Self::KitchenSink => "kitchen_sink".to_string(),
+        };
+
+        Some(PossibleValue::new(value))
+    }
+}

crates/storybook2/src/ui.rs 🔗

@@ -2,9 +2,11 @@ mod children;
 mod components;
 mod elements;
 pub mod prelude;
+mod theme;
 mod tokens;
 
 pub use children::*;
 pub use components::*;
 pub use elements::*;
+pub use theme::*;
 pub use tokens::*;

crates/storybook2/src/ui/elements/label.rs 🔗

@@ -0,0 +1,165 @@
+use std::marker::PhantomData;
+
+use gpui3::{Hsla, WindowContext};
+use smallvec::SmallVec;
+
+use crate::theme::theme;
+use crate::ui::prelude::*;
+
+#[derive(Default, PartialEq, Copy, Clone)]
+pub enum LabelColor {
+    #[default]
+    Default,
+    Muted,
+    Created,
+    Modified,
+    Deleted,
+    Disabled,
+    Hidden,
+    Placeholder,
+    Accent,
+}
+
+impl LabelColor {
+    pub fn hsla(&self, cx: &WindowContext) -> Hsla {
+        let theme = theme(cx);
+
+        match self {
+            Self::Default => theme.middle.base.default.foreground,
+            Self::Muted => theme.middle.variant.default.foreground,
+            Self::Created => theme.middle.positive.default.foreground,
+            Self::Modified => theme.middle.warning.default.foreground,
+            Self::Deleted => theme.middle.negative.default.foreground,
+            Self::Disabled => theme.middle.base.disabled.foreground,
+            Self::Hidden => theme.middle.variant.default.foreground,
+            Self::Placeholder => theme.middle.base.disabled.foreground,
+            Self::Accent => theme.middle.accent.default.foreground,
+        }
+    }
+}
+
+#[derive(Default, PartialEq, Copy, Clone)]
+pub enum LabelSize {
+    #[default]
+    Default,
+    Small,
+}
+
+#[derive(Element, Clone)]
+pub struct Label<S: 'static + Send + Sync> {
+    state_type: PhantomData<S>,
+    label: String,
+    color: LabelColor,
+    size: LabelSize,
+    highlight_indices: Vec<usize>,
+    strikethrough: bool,
+}
+
+impl<S: 'static + Send + Sync> Label<S> {
+    pub fn new<L>(label: L) -> Self
+    where
+        L: Into<String>,
+    {
+        Self {
+            state_type: PhantomData,
+            label: label.into(),
+            color: LabelColor::Default,
+            size: LabelSize::Default,
+            highlight_indices: Vec::new(),
+            strikethrough: false,
+        }
+    }
+
+    pub fn color(mut self, color: LabelColor) -> Self {
+        self.color = color;
+        self
+    }
+
+    pub fn size(mut self, size: LabelSize) -> Self {
+        self.size = size;
+        self
+    }
+
+    pub fn with_highlights(mut self, indices: Vec<usize>) -> Self {
+        self.highlight_indices = indices;
+        self
+    }
+
+    pub fn set_strikethrough(mut self, strikethrough: bool) -> Self {
+        self.strikethrough = strikethrough;
+        self
+    }
+
+    fn render(&mut self, cx: &mut ViewContext<S>) -> impl Element<State = S> {
+        let theme = theme(cx);
+
+        let highlight_color = theme.lowest.accent.default.foreground;
+
+        let mut highlight_indices = self.highlight_indices.iter().copied().peekable();
+
+        let mut runs: SmallVec<[Run; 8]> = SmallVec::new();
+
+        for (char_ix, char) in self.label.char_indices() {
+            let mut color = self.color.hsla(cx);
+
+            if let Some(highlight_ix) = highlight_indices.peek() {
+                if char_ix == *highlight_ix {
+                    color = highlight_color;
+
+                    highlight_indices.next();
+                }
+            }
+
+            let last_run = runs.last_mut();
+
+            let start_new_run = if let Some(last_run) = last_run {
+                if color == last_run.color {
+                    last_run.text.push(char);
+                    false
+                } else {
+                    true
+                }
+            } else {
+                true
+            };
+
+            if start_new_run {
+                runs.push(Run {
+                    text: char.to_string(),
+                    color,
+                });
+            }
+        }
+
+        div()
+            .flex()
+            // .when(self.strikethrough, |this| {
+            //     this.relative().child(
+            //         div()
+            //             .absolute()
+            //             .top_px()
+            //             .my_auto()
+            //             .w_full()
+            //             .h_px()
+            //             .fill(LabelColor::Hidden.hsla(cx)),
+            //     )
+            // })
+            .children(runs.into_iter().map(|run| {
+                let mut div = div();
+
+                if self.size == LabelSize::Small {
+                    div = div.text_xs();
+                } else {
+                    div = div.text_sm();
+                }
+
+                div.text_color(run.color).child(run.text)
+            }))
+    }
+}
+
+/// A run of text that receives the same style.
+struct Run {
+    pub text: String,
+    pub color: Hsla,
+}

crates/storybook2/src/ui/prelude.rs 🔗

@@ -1,3 +1,5 @@
-pub use gpui3::{Element, IntoAnyElement, ParentElement, ScrollState, StyleHelpers, ViewContext};
+pub use gpui3::{
+    div, Element, IntoAnyElement, ParentElement, ScrollState, StyleHelpers, ViewContext,
+};
 
 pub use crate::ui::{HackyChildren, HackyChildrenPayload};

crates/storybook2/src/ui/theme.rs 🔗

@@ -0,0 +1,10 @@
+use std::sync::Arc;
+
+use gpui3::WindowContext;
+
+use crate::theme::Theme;
+use crate::themes::rose_pine_dawn;
+
+pub fn theme(cx: &WindowContext) -> Arc<Theme> {
+    Arc::new(rose_pine_dawn())
+}