Add disclosable component (#2868)

Mikayla Maki created

This PR adds a disclosable component, related wiring, and uses it to
implement the collaboration panel's disclosure of subchannels. It also
adds a component test page to make style development easier, and
refactors components into v0.2, safe styles (as described in [TWAZ
#16](https://zed.dev/blog/this-week-at-zed-16))

Release Notes:

- N/A

Change summary

Cargo.lock                                  |  14 +
Cargo.toml                                  |   1 
crates/client/src/channel_store.rs          |  10 
crates/collab_ui/src/collab_panel.rs        | 124 ++++++++
crates/component_test/Cargo.toml            |  18 +
crates/component_test/src/component_test.rs | 121 ++++++++
crates/gpui/examples/components.rs          |  10 
crates/gpui/src/elements.rs                 |  21 +
crates/gpui/src/elements/component.rs       | 215 +++++++++++++--
crates/gpui/src/elements/container.rs       |   8 
crates/gpui/src/elements/flex.rs            |  33 +
crates/search/src/search.rs                 |  21 
crates/theme/src/components.rs              | 315 ++++++++++++++++------
crates/theme/src/theme.rs                   |  29 +
crates/zed/Cargo.toml                       |   1 
crates/zed/src/main.rs                      |   1 
styles/src/component/icon_button.ts         |  14 
styles/src/style_tree/app.ts                |   5 
styles/src/style_tree/collab_panel.ts       |   9 
styles/src/style_tree/component_test.ts     |  27 +
20 files changed, 833 insertions(+), 164 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -1556,6 +1556,19 @@ dependencies = [
  "workspace",
 ]
 
+[[package]]
+name = "component_test"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "gpui",
+ "project",
+ "settings",
+ "theme",
+ "util",
+ "workspace",
+]
+
 [[package]]
 name = "concurrent-queue"
 version = "2.2.0"
@@ -9653,6 +9666,7 @@ dependencies = [
  "collab_ui",
  "collections",
  "command_palette",
+ "component_test",
  "context_menu",
  "copilot",
  "copilot_button",

Cargo.toml 🔗

@@ -13,6 +13,7 @@ members = [
     "crates/collab_ui",
     "crates/collections",
     "crates/command_palette",
+    "crates/component_test",
     "crates/context_menu",
     "crates/copilot",
     "crates/copilot_button",

crates/client/src/channel_store.rs 🔗

@@ -114,6 +114,16 @@ impl ChannelStore {
         }
     }
 
+    pub fn has_children(&self, channel_id: ChannelId) -> bool {
+        self.channel_paths.iter().any(|path| {
+            if let Some(ix) = path.iter().position(|id| *id == channel_id) {
+                path.len() > ix + 1
+            } else {
+                false
+            }
+        })
+    }
+
     pub fn channel_count(&self) -> usize {
         self.channel_paths.len()
     }

crates/collab_ui/src/collab_panel.rs 🔗

@@ -16,8 +16,9 @@ use fuzzy::{match_strings, StringMatchCandidate};
 use gpui::{
     actions,
     elements::{
-        Canvas, ChildView, Empty, Flex, Image, Label, List, ListOffset, ListState,
-        MouseEventHandler, Orientation, OverlayPositionMode, Padding, ParentElement, Stack, Svg,
+        Canvas, ChildView, Component, Empty, Flex, Image, Label, List, ListOffset, ListState,
+        MouseEventHandler, Orientation, OverlayPositionMode, Padding, ParentElement, SafeStylable,
+        Stack, Svg,
     },
     geometry::{
         rect::RectF,
@@ -35,7 +36,7 @@ use serde_derive::{Deserialize, Serialize};
 use settings::SettingsStore;
 use staff_mode::StaffMode;
 use std::{borrow::Cow, mem, sync::Arc};
-use theme::IconButton;
+use theme::{components::ComponentExt, IconButton};
 use util::{iife, ResultExt, TryFutureExt};
 use workspace::{
     dock::{DockPosition, Panel},
@@ -53,6 +54,11 @@ struct RemoveChannel {
     channel_id: u64,
 }
 
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+struct ToggleCollapse {
+    channel_id: u64,
+}
+
 #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
 struct NewChannel {
     channel_id: u64,
@@ -73,7 +79,16 @@ struct RenameChannel {
     channel_id: u64,
 }
 
-actions!(collab_panel, [ToggleFocus, Remove, Secondary]);
+actions!(
+    collab_panel,
+    [
+        ToggleFocus,
+        Remove,
+        Secondary,
+        CollapseSelectedChannel,
+        ExpandSelectedChannel
+    ]
+);
 
 impl_actions!(
     collab_panel,
@@ -82,7 +97,8 @@ impl_actions!(
         NewChannel,
         InviteMembers,
         ManageMembers,
-        RenameChannel
+        RenameChannel,
+        ToggleCollapse
     ]
 );
 
@@ -105,6 +121,9 @@ pub fn init(_client: Arc<Client>, cx: &mut AppContext) {
     cx.add_action(CollabPanel::manage_members);
     cx.add_action(CollabPanel::rename_selected_channel);
     cx.add_action(CollabPanel::rename_channel);
+    cx.add_action(CollabPanel::toggle_channel_collapsed);
+    cx.add_action(CollabPanel::collapse_selected_channel);
+    cx.add_action(CollabPanel::expand_selected_channel)
 }
 
 #[derive(Debug)]
@@ -147,6 +166,7 @@ pub struct CollabPanel {
     list_state: ListState<Self>,
     subscriptions: Vec<Subscription>,
     collapsed_sections: Vec<Section>,
+    collapsed_channels: Vec<ChannelId>,
     workspace: WeakViewHandle<Workspace>,
     context_menu_on_selected: bool,
 }
@@ -398,6 +418,7 @@ impl CollabPanel {
                 subscriptions: Vec::default(),
                 match_candidates: Vec::default(),
                 collapsed_sections: vec![Section::Offline],
+                collapsed_channels: Vec::default(),
                 workspace: workspace.weak_handle(),
                 client: workspace.app_state().client.clone(),
                 context_menu_on_selected: true,
@@ -657,10 +678,24 @@ impl CollabPanel {
                         self.entries.push(ListEntry::ChannelEditor { depth: 0 });
                     }
                 }
+                let mut collapse_depth = None;
                 for mat in matches {
                     let (depth, channel) =
                         channel_store.channel_at_index(mat.candidate_id).unwrap();
 
+                    if collapse_depth.is_none() && self.is_channel_collapsed(channel.id) {
+                        collapse_depth = Some(depth);
+                    } else if let Some(collapsed_depth) = collapse_depth {
+                        if depth > collapsed_depth {
+                            continue;
+                        }
+                        if self.is_channel_collapsed(channel.id) {
+                            collapse_depth = Some(depth);
+                        } else {
+                            collapse_depth = None;
+                        }
+                    }
+
                     match &self.channel_editing_state {
                         Some(ChannelEditingState::Create { parent_id, .. })
                             if *parent_id == Some(channel.id) =>
@@ -1332,7 +1367,7 @@ impl CollabPanel {
                 .with_cursor_style(CursorStyle::PointingHand)
                 .on_click(MouseButton::Left, move |_, this, cx| {
                     if can_collapse {
-                        this.toggle_expanded(section, cx);
+                        this.toggle_section_expanded(section, cx);
                     }
                 })
         }
@@ -1479,6 +1514,11 @@ impl CollabPanel {
         cx: &AppContext,
     ) -> AnyElement<Self> {
         Flex::row()
+            .with_child(
+                Empty::new()
+                    .constrained()
+                    .with_width(theme.collab_panel.disclosure.button_space()),
+            )
             .with_child(
                 Svg::new("icons/hash.svg")
                     .with_color(theme.collab_panel.channel_hash.color)
@@ -1537,6 +1577,10 @@ impl CollabPanel {
         cx: &mut ViewContext<Self>,
     ) -> AnyElement<Self> {
         let channel_id = channel.id;
+        let has_children = self.channel_store.read(cx).has_children(channel_id);
+        let disclosed =
+            has_children.then(|| !self.collapsed_channels.binary_search(&channel_id).is_ok());
+
         let is_active = iife!({
             let call_channel = ActiveCall::global(cx)
                 .read(cx)
@@ -1550,7 +1594,7 @@ impl CollabPanel {
         const FACEPILE_LIMIT: usize = 3;
 
         MouseEventHandler::new::<Channel, _>(channel.id as usize, cx, |state, cx| {
-            Flex::row()
+            Flex::<Self>::row()
                 .with_child(
                     Svg::new("icons/hash.svg")
                         .with_color(theme.channel_hash.color)
@@ -1599,6 +1643,11 @@ impl CollabPanel {
                     }
                 })
                 .align_children_center()
+                .styleable_component()
+                .disclosable(disclosed, Box::new(ToggleCollapse { channel_id }))
+                .with_id(channel_id as usize)
+                .with_style(theme.disclosure.clone())
+                .element()
                 .constrained()
                 .with_height(theme.row_height)
                 .contained()
@@ -1825,6 +1874,12 @@ impl CollabPanel {
                     OverlayPositionMode::Window
                 });
 
+                let expand_action_name = if self.is_channel_collapsed(channel_id) {
+                    "Expand Subchannels"
+                } else {
+                    "Collapse Subchannels"
+                };
+
                 context_menu.show(
                     position.unwrap_or_default(),
                     if self.context_menu_on_selected {
@@ -1833,6 +1888,7 @@ impl CollabPanel {
                         gpui::elements::AnchorCorner::BottomLeft
                     },
                     vec![
+                        ContextMenuItem::action(expand_action_name, ToggleCollapse { channel_id }),
                         ContextMenuItem::action("New Subchannel", NewChannel { channel_id }),
                         ContextMenuItem::Separator,
                         ContextMenuItem::action("Invite to Channel", InviteMembers { channel_id }),
@@ -1912,7 +1968,7 @@ impl CollabPanel {
                         | Section::Online
                         | Section::Offline
                         | Section::ChannelInvites => {
-                            self.toggle_expanded(*section, cx);
+                            self.toggle_section_expanded(*section, cx);
                         }
                     },
                     ListEntry::Contact { contact, calling } => {
@@ -2000,7 +2056,7 @@ impl CollabPanel {
         }
     }
 
-    fn toggle_expanded(&mut self, section: Section, cx: &mut ViewContext<Self>) {
+    fn toggle_section_expanded(&mut self, section: Section, cx: &mut ViewContext<Self>) {
         if let Some(ix) = self.collapsed_sections.iter().position(|s| *s == section) {
             self.collapsed_sections.remove(ix);
         } else {
@@ -2009,6 +2065,54 @@ impl CollabPanel {
         self.update_entries(false, cx);
     }
 
+    fn collapse_selected_channel(
+        &mut self,
+        _: &CollapseSelectedChannel,
+        cx: &mut ViewContext<Self>,
+    ) {
+        let Some(channel_id) = self.selected_channel().map(|channel| channel.id) else {
+            return;
+        };
+
+        if self.is_channel_collapsed(channel_id) {
+            return;
+        }
+
+        self.toggle_channel_collapsed(&ToggleCollapse { channel_id }, cx)
+    }
+
+    fn expand_selected_channel(&mut self, _: &ExpandSelectedChannel, cx: &mut ViewContext<Self>) {
+        let Some(channel_id) = self.selected_channel().map(|channel| channel.id) else {
+            return;
+        };
+
+        if !self.is_channel_collapsed(channel_id) {
+            return;
+        }
+
+        self.toggle_channel_collapsed(&ToggleCollapse { channel_id }, cx)
+    }
+
+    fn toggle_channel_collapsed(&mut self, action: &ToggleCollapse, cx: &mut ViewContext<Self>) {
+        let channel_id = action.channel_id;
+
+        match self.collapsed_channels.binary_search(&channel_id) {
+            Ok(ix) => {
+                self.collapsed_channels.remove(ix);
+            }
+            Err(ix) => {
+                self.collapsed_channels.insert(ix, channel_id);
+            }
+        };
+        self.update_entries(true, cx);
+        cx.notify();
+        cx.focus_self();
+    }
+
+    fn is_channel_collapsed(&self, channel: ChannelId) -> bool {
+        self.collapsed_channels.binary_search(&channel).is_ok()
+    }
+
     fn leave_call(cx: &mut ViewContext<Self>) {
         ActiveCall::global(cx)
             .update(cx, |call, cx| call.hang_up(cx))
@@ -2048,6 +2152,8 @@ impl CollabPanel {
     }
 
     fn new_subchannel(&mut self, action: &NewChannel, cx: &mut ViewContext<Self>) {
+        self.collapsed_channels
+            .retain(|&channel| channel != action.channel_id);
         self.channel_editing_state = Some(ChannelEditingState::Create {
             parent_id: Some(action.channel_id),
             pending_name: None,

crates/component_test/Cargo.toml 🔗

@@ -0,0 +1,18 @@
+[package]
+name = "component_test"
+version = "0.1.0"
+edition = "2021"
+publish = false
+
+[lib]
+path = "src/component_test.rs"
+doctest = false
+
+[dependencies]
+anyhow.workspace = true
+gpui = { path = "../gpui" }
+settings = { path = "../settings" }
+util = { path = "../util" }
+theme = { path = "../theme" }
+workspace = { path = "../workspace" }
+project = { path = "../project" }

crates/component_test/src/component_test.rs 🔗

@@ -0,0 +1,121 @@
+use gpui::{
+    actions,
+    elements::{Component, Flex, ParentElement, SafeStylable},
+    AppContext, Element, Entity, ModelHandle, Task, View, ViewContext, ViewHandle, WeakViewHandle,
+};
+use project::Project;
+use theme::components::{action_button::Button, label::Label, ComponentExt};
+use workspace::{
+    item::Item, register_deserializable_item, ItemId, Pane, PaneBackdrop, Workspace, WorkspaceId,
+};
+
+pub fn init(cx: &mut AppContext) {
+    cx.add_action(ComponentTest::toggle_disclosure);
+    cx.add_action(ComponentTest::toggle_toggle);
+    cx.add_action(ComponentTest::deploy);
+    register_deserializable_item::<ComponentTest>(cx);
+}
+
+actions!(
+    test,
+    [NoAction, ToggleDisclosure, ToggleToggle, NewComponentTest]
+);
+
+struct ComponentTest {
+    disclosed: bool,
+    toggled: bool,
+}
+
+impl ComponentTest {
+    fn new() -> Self {
+        Self {
+            disclosed: false,
+            toggled: false,
+        }
+    }
+
+    fn deploy(workspace: &mut Workspace, _: &NewComponentTest, cx: &mut ViewContext<Workspace>) {
+        workspace.add_item(Box::new(cx.add_view(|_| ComponentTest::new())), cx);
+    }
+
+    fn toggle_disclosure(&mut self, _: &ToggleDisclosure, cx: &mut ViewContext<Self>) {
+        self.disclosed = !self.disclosed;
+        cx.notify();
+    }
+
+    fn toggle_toggle(&mut self, _: &ToggleToggle, cx: &mut ViewContext<Self>) {
+        self.toggled = !self.toggled;
+        cx.notify();
+    }
+}
+
+impl Entity for ComponentTest {
+    type Event = ();
+}
+
+impl View for ComponentTest {
+    fn ui_name() -> &'static str {
+        "Component Test"
+    }
+
+    fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> gpui::AnyElement<Self> {
+        let theme = theme::current(cx);
+
+        PaneBackdrop::new(
+            cx.view_id(),
+            Flex::column()
+                .with_spacing(10.)
+                .with_child(
+                    Button::action(NoAction)
+                        .with_tooltip("Here's what a tooltip looks like", theme.tooltip.clone())
+                        .with_contents(Label::new("Click me!"))
+                        .with_style(theme.component_test.button.clone())
+                        .element(),
+                )
+                .with_child(
+                    Button::action(ToggleToggle)
+                        .with_tooltip("Here's what a tooltip looks like", theme.tooltip.clone())
+                        .with_contents(Label::new("Toggle me!"))
+                        .toggleable(self.toggled)
+                        .with_style(theme.component_test.toggle.clone())
+                        .element(),
+                )
+                .with_child(
+                    Label::new("A disclosure")
+                        .disclosable(Some(self.disclosed), Box::new(ToggleDisclosure))
+                        .with_style(theme.component_test.disclosure.clone())
+                        .element(),
+                )
+                .constrained()
+                .with_width(200.)
+                .aligned()
+                .into_any(),
+        )
+        .into_any()
+    }
+}
+
+impl Item for ComponentTest {
+    fn tab_content<V: 'static>(
+        &self,
+        _: Option<usize>,
+        style: &theme::Tab,
+        _: &AppContext,
+    ) -> gpui::AnyElement<V> {
+        gpui::elements::Label::new("Component test", style.label.clone()).into_any()
+    }
+
+    fn serialized_item_kind() -> Option<&'static str> {
+        Some("ComponentTest")
+    }
+
+    fn deserialize(
+        _project: ModelHandle<Project>,
+        _workspace: WeakViewHandle<Workspace>,
+        _workspace_id: WorkspaceId,
+        _item_id: ItemId,
+        cx: &mut ViewContext<Pane>,
+    ) -> Task<anyhow::Result<ViewHandle<Self>>> {
+        Task::ready(Ok(cx.add_view(|_| Self::new())))
+    }
+}

crates/gpui/examples/components.rs 🔗

@@ -2,7 +2,7 @@ use button_component::Button;
 
 use gpui::{
     color::Color,
-    elements::{Component, ContainerStyle, Flex, Label, ParentElement},
+    elements::{ContainerStyle, Flex, Label, ParentElement, StatefulComponent},
     fonts::{self, TextStyle},
     platform::WindowOptions,
     AnyElement, App, Element, Entity, View, ViewContext,
@@ -114,7 +114,7 @@ mod theme {
 // Component creation:
 mod toggleable_button {
     use gpui::{
-        elements::{Component, ContainerStyle, LabelStyle},
+        elements::{ContainerStyle, LabelStyle, StatefulComponent},
         scene::MouseClick,
         EventContext, View,
     };
@@ -156,7 +156,7 @@ mod toggleable_button {
         }
     }
 
-    impl<V: View> Component<V> for ToggleableButton<V> {
+    impl<V: View> StatefulComponent<V> for ToggleableButton<V> {
         fn render(self, v: &mut V, cx: &mut gpui::ViewContext<V>) -> gpui::AnyElement<V> {
             let button = if let Some(style) = self.style {
                 self.button.with_style(*style.style_for(self.active))
@@ -171,7 +171,7 @@ mod toggleable_button {
 mod button_component {
 
     use gpui::{
-        elements::{Component, ContainerStyle, Label, LabelStyle, MouseEventHandler},
+        elements::{ContainerStyle, Label, LabelStyle, MouseEventHandler, StatefulComponent},
         platform::MouseButton,
         scene::MouseClick,
         AnyElement, Element, EventContext, TypeTag, View, ViewContext,
@@ -212,7 +212,7 @@ mod button_component {
         }
     }
 
-    impl<V: View> Component<V> for Button<V> {
+    impl<V: View> StatefulComponent<V> for Button<V> {
         fn render(self, _: &mut V, cx: &mut ViewContext<V>) -> AnyElement<V> {
             let click_handler = self.click_handler;
 

crates/gpui/src/elements.rs 🔗

@@ -234,6 +234,27 @@ pub trait Element<V: 'static>: 'static {
     {
         MouseEventHandler::for_child::<Tag>(self.into_any(), region_id)
     }
+
+    fn component(self) -> StatelessElementAdapter
+    where
+        Self: Sized,
+    {
+        StatelessElementAdapter::new(self.into_any())
+    }
+
+    fn stateful_component(self) -> StatefulElementAdapter<V>
+    where
+        Self: Sized,
+    {
+        StatefulElementAdapter::new(self.into_any())
+    }
+
+    fn styleable_component(self) -> StylableAdapter<StatelessElementAdapter>
+    where
+        Self: Sized,
+    {
+        StatelessElementAdapter::new(self.into_any()).stylable()
+    }
 }
 
 trait AnyElementState<V> {

crates/gpui/src/elements/component.rs 🔗

@@ -1,47 +1,96 @@
-use std::marker::PhantomData;
+use std::{any::Any, marker::PhantomData};
 
 use pathfinder_geometry::{rect::RectF, vector::Vector2F};
 
 use crate::{
-    AnyElement, Element, LayoutContext, PaintContext, SceneBuilder, SizeConstraint, View,
-    ViewContext,
+    AnyElement, Element, LayoutContext, PaintContext, SceneBuilder, SizeConstraint, ViewContext,
 };
 
 use super::Empty;
 
-pub trait GeneralComponent {
-    fn render<V: View>(self, v: &mut V, cx: &mut ViewContext<V>) -> AnyElement<V>;
-    fn element<V: View>(self) -> ComponentAdapter<V, Self>
+/// The core stateless component trait, simply rendering an element tree
+pub trait Component {
+    fn render<V: 'static>(self, cx: &mut ViewContext<V>) -> AnyElement<V>;
+
+    fn element<V: 'static>(self) -> ComponentAdapter<V, Self>
     where
         Self: Sized,
     {
         ComponentAdapter::new(self)
     }
+
+    fn stylable(self) -> StylableAdapter<Self>
+    where
+        Self: Sized,
+    {
+        StylableAdapter::new(self)
+    }
+
+    fn stateful<V: 'static>(self) -> StatefulAdapter<Self, V>
+    where
+        Self: Sized,
+    {
+        StatefulAdapter::new(self)
+    }
 }
 
-pub trait StyleableComponent {
+/// Allows a a component's styles to be rebound in a simple way.
+pub trait Stylable: Component {
     type Style: Clone;
-    type Output: GeneralComponent;
+
+    fn with_style(self, style: Self::Style) -> Self;
+}
+
+/// This trait models the typestate pattern for a component's style,
+/// enforcing at compile time that a component is only usable after
+/// it has been styled while still allowing for late binding of the
+/// styling information
+pub trait SafeStylable {
+    type Style: Clone;
+    type Output: Component;
 
     fn with_style(self, style: Self::Style) -> Self::Output;
 }
 
-impl GeneralComponent for () {
-    fn render<V: View>(self, _: &mut V, _: &mut ViewContext<V>) -> AnyElement<V> {
-        Empty::new().into_any()
+/// All stylable components can trivially implement SafeStylable
+impl<C: Stylable> SafeStylable for C {
+    type Style = C::Style;
+
+    type Output = C;
+
+    fn with_style(self, style: Self::Style) -> Self::Output {
+        self.with_style(style)
+    }
+}
+
+/// Allows converting an unstylable component into a stylable one
+/// by using `()` as the style type
+pub struct StylableAdapter<C: Component> {
+    component: C,
+}
+
+impl<C: Component> StylableAdapter<C> {
+    pub fn new(component: C) -> Self {
+        Self { component }
     }
 }
 
-impl StyleableComponent for () {
+impl<C: Component> SafeStylable for StylableAdapter<C> {
     type Style = ();
-    type Output = ();
+
+    type Output = C;
 
     fn with_style(self, _: Self::Style) -> Self::Output {
-        ()
+        self.component
     }
 }
 
-pub trait Component<V: View> {
+/// This is a secondary trait for components that can be styled
+/// which rely on their view's state. This is useful for components that, for example,
+/// want to take click handler callbacks Unfortunately, the generic bound on the
+/// Component trait makes it incompatible with the stateless components above.
+// So let's just replicate them for now
+pub trait StatefulComponent<V: 'static> {
     fn render(self, v: &mut V, cx: &mut ViewContext<V>) -> AnyElement<V>;
 
     fn element(self) -> ComponentAdapter<V, Self>
@@ -50,21 +99,63 @@ pub trait Component<V: View> {
     {
         ComponentAdapter::new(self)
     }
+
+    fn styleable(self) -> StatefulStylableAdapter<Self, V>
+    where
+        Self: Sized,
+    {
+        StatefulStylableAdapter::new(self)
+    }
+
+    fn stateless(self) -> StatelessElementAdapter
+    where
+        Self: Sized + 'static,
+    {
+        StatelessElementAdapter::new(self.element().into_any())
+    }
 }
 
-impl<V: View, C: GeneralComponent> Component<V> for C {
-    fn render(self, v: &mut V, cx: &mut ViewContext<V>) -> AnyElement<V> {
-        self.render(v, cx)
+/// It is trivial to convert stateless components to stateful components, so lets
+/// do so en masse. Note that the reverse is impossible without a helper.
+impl<V: 'static, C: Component> StatefulComponent<V> for C {
+    fn render(self, _: &mut V, cx: &mut ViewContext<V>) -> AnyElement<V> {
+        self.render(cx)
     }
 }
 
-// StylableComponent -> GeneralComponent
-pub struct StylableComponentAdapter<C: Component<V>, V: View> {
+/// Same as stylable, but generic over a view type
+pub trait StatefulStylable<V: 'static>: StatefulComponent<V> {
+    type Style: Clone;
+
+    fn with_style(self, style: Self::Style) -> Self;
+}
+
+/// Same as SafeStylable, but generic over a view type
+pub trait StatefulSafeStylable<V: 'static> {
+    type Style: Clone;
+    type Output: StatefulComponent<V>;
+
+    fn with_style(self, style: Self::Style) -> Self::Output;
+}
+
+/// Converting from stateless to stateful
+impl<V: 'static, C: SafeStylable> StatefulSafeStylable<V> for C {
+    type Style = C::Style;
+
+    type Output = C::Output;
+
+    fn with_style(self, style: Self::Style) -> Self::Output {
+        self.with_style(style)
+    }
+}
+
+// A helper for converting stateless components into stateful ones
+pub struct StatefulAdapter<C, V> {
     component: C,
     phantom: std::marker::PhantomData<V>,
 }
 
-impl<C: Component<V>, V: View> StylableComponentAdapter<C, V> {
+impl<C: Component, V: 'static> StatefulAdapter<C, V> {
     pub fn new(component: C) -> Self {
         Self {
             component,
@@ -73,7 +164,31 @@ impl<C: Component<V>, V: View> StylableComponentAdapter<C, V> {
     }
 }
 
-impl<C: GeneralComponent, V: View> StyleableComponent for StylableComponentAdapter<C, V> {
+impl<C: Component, V: 'static> StatefulComponent<V> for StatefulAdapter<C, V> {
+    fn render(self, _: &mut V, cx: &mut ViewContext<V>) -> AnyElement<V> {
+        self.component.render(cx)
+    }
+}
+
+// A helper for converting stateful but style-less components into stylable ones
+// by using `()` as the style type
+pub struct StatefulStylableAdapter<C: StatefulComponent<V>, V: 'static> {
+    component: C,
+    phantom: std::marker::PhantomData<V>,
+}
+
+impl<C: StatefulComponent<V>, V: 'static> StatefulStylableAdapter<C, V> {
+    pub fn new(component: C) -> Self {
+        Self {
+            component,
+            phantom: std::marker::PhantomData,
+        }
+    }
+}
+
+impl<C: StatefulComponent<V>, V: 'static> StatefulSafeStylable<V>
+    for StatefulStylableAdapter<C, V>
+{
     type Style = ();
 
     type Output = C;
@@ -83,13 +198,37 @@ impl<C: GeneralComponent, V: View> StyleableComponent for StylableComponentAdapt
     }
 }
 
-// Element -> Component
-pub struct ElementAdapter<V: View> {
+/// A way of erasing the view generic from an element, useful
+/// for wrapping up an explicit element tree into stateless
+/// components
+pub struct StatelessElementAdapter {
+    element: Box<dyn Any>,
+}
+
+impl StatelessElementAdapter {
+    pub fn new<V: 'static>(element: AnyElement<V>) -> Self {
+        StatelessElementAdapter {
+            element: Box::new(element) as Box<dyn Any>,
+        }
+    }
+}
+
+impl Component for StatelessElementAdapter {
+    fn render<V: 'static>(self, _: &mut ViewContext<V>) -> AnyElement<V> {
+        *self
+            .element
+            .downcast::<AnyElement<V>>()
+            .expect("Don't move elements out of their view :(")
+    }
+}
+
+// For converting elements into stateful components
+pub struct StatefulElementAdapter<V: 'static> {
     element: AnyElement<V>,
     _phantom: std::marker::PhantomData<V>,
 }
 
-impl<V: View> ElementAdapter<V> {
+impl<V: 'static> StatefulElementAdapter<V> {
     pub fn new(element: AnyElement<V>) -> Self {
         Self {
             element,
@@ -98,20 +237,35 @@ impl<V: View> ElementAdapter<V> {
     }
 }
 
-impl<V: View> Component<V> for ElementAdapter<V> {
+impl<V: 'static> StatefulComponent<V> for StatefulElementAdapter<V> {
     fn render(self, _: &mut V, _: &mut ViewContext<V>) -> AnyElement<V> {
         self.element
     }
 }
 
-// Component -> Element
-pub struct ComponentAdapter<V: View, E> {
+/// A convenient shorthand for creating an empty component.
+impl Component for () {
+    fn render<V: 'static>(self, _: &mut ViewContext<V>) -> AnyElement<V> {
+        Empty::new().into_any()
+    }
+}
+
+impl Stylable for () {
+    type Style = ();
+
+    fn with_style(self, _: Self::Style) -> Self {
+        ()
+    }
+}
+
+// For converting components back into Elements
+pub struct ComponentAdapter<V: 'static, E> {
     component: Option<E>,
     element: Option<AnyElement<V>>,
     phantom: PhantomData<V>,
 }
 
-impl<E, V: View> ComponentAdapter<V, E> {
+impl<E, V: 'static> ComponentAdapter<V, E> {
     pub fn new(e: E) -> Self {
         Self {
             component: Some(e),
@@ -121,7 +275,7 @@ impl<E, V: View> ComponentAdapter<V, E> {
     }
 }
 
-impl<V: View, C: Component<V> + 'static> Element<V> for ComponentAdapter<V, C> {
+impl<V: 'static, C: StatefulComponent<V> + 'static> Element<V> for ComponentAdapter<V, C> {
     type LayoutState = ();
 
     type PaintState = ();
@@ -184,6 +338,7 @@ impl<V: View, C: Component<V> + 'static> Element<V> for ComponentAdapter<V, C> {
     ) -> serde_json::Value {
         serde_json::json!({
             "type": "ComponentAdapter",
+            "component": std::any::type_name::<C>(),
             "child": self.element.as_ref().map(|el| el.debug(view, cx)),
         })
     }

crates/gpui/src/elements/container.rs 🔗

@@ -44,6 +44,14 @@ impl ContainerStyle {
             ..Default::default()
         }
     }
+
+    pub fn additional_length(&self) -> f32 {
+        self.padding.left
+            + self.padding.right
+            + self.border.width * 2.
+            + self.margin.left
+            + self.margin.right
+    }
 }
 
 pub struct Container<V> {

crates/gpui/src/elements/flex.rs 🔗

@@ -22,6 +22,7 @@ pub struct Flex<V> {
     children: Vec<AnyElement<V>>,
     scroll_state: Option<(ElementStateHandle<Rc<ScrollState>>, usize)>,
     child_alignment: f32,
+    spacing: f32,
 }
 
 impl<V: 'static> Flex<V> {
@@ -31,6 +32,7 @@ impl<V: 'static> Flex<V> {
             children: Default::default(),
             scroll_state: None,
             child_alignment: -1.,
+            spacing: 0.,
         }
     }
 
@@ -51,6 +53,11 @@ impl<V: 'static> Flex<V> {
         self
     }
 
+    pub fn with_spacing(mut self, spacing: f32) -> Self {
+        self.spacing = spacing;
+        self
+    }
+
     pub fn scrollable<Tag>(
         mut self,
         element_id: usize,
@@ -81,7 +88,8 @@ impl<V: 'static> Flex<V> {
         cx: &mut LayoutContext<V>,
     ) {
         let cross_axis = self.axis.invert();
-        for child in &mut self.children {
+        let last = self.children.len() - 1;
+        for (ix, child) in &mut self.children.iter_mut().enumerate() {
             if let Some(metadata) = child.metadata::<FlexParentData>() {
                 if let Some((flex, expanded)) = metadata.flex {
                     if expanded != layout_expanded {
@@ -93,6 +101,10 @@ impl<V: 'static> Flex<V> {
                     } else {
                         let space_per_flex = *remaining_space / *remaining_flex;
                         space_per_flex * flex
+                    } - if ix == 0 || ix == last {
+                        self.spacing / 2.
+                    } else {
+                        self.spacing
                     };
                     let child_min = if expanded { child_max } else { 0. };
                     let child_constraint = match self.axis {
@@ -137,7 +149,8 @@ impl<V: 'static> Element<V> for Flex<V> {
 
         let cross_axis = self.axis.invert();
         let mut cross_axis_max: f32 = 0.0;
-        for child in &mut self.children {
+        let last = self.children.len().saturating_sub(1);
+        for (ix, child) in &mut self.children.iter_mut().enumerate() {
             let metadata = child.metadata::<FlexParentData>();
             contains_float |= metadata.map_or(false, |metadata| metadata.float);
 
@@ -155,7 +168,12 @@ impl<V: 'static> Element<V> for Flex<V> {
                     ),
                 };
                 let size = child.layout(child_constraint, view, cx);
-                fixed_space += size.along(self.axis);
+                fixed_space += size.along(self.axis)
+                    + if ix == 0 || ix == last {
+                        self.spacing / 2.
+                    } else {
+                        self.spacing
+                    };
                 cross_axis_max = cross_axis_max.max(size.along(cross_axis));
             }
         }
@@ -315,7 +333,8 @@ impl<V: 'static> Element<V> for Flex<V> {
             }
         }
 
-        for child in &mut self.children {
+        let last = self.children.len().saturating_sub(1);
+        for (ix, child) in &mut self.children.iter_mut().enumerate() {
             if remaining_space > 0. {
                 if let Some(metadata) = child.metadata::<FlexParentData>() {
                     if metadata.float {
@@ -353,9 +372,11 @@ impl<V: 'static> Element<V> for Flex<V> {
 
             child.paint(scene, aligned_child_origin, visible_bounds, view, cx);
 
+            let spacing = if ix == last { 0. } else { self.spacing };
+
             match self.axis {
-                Axis::Horizontal => child_origin += vec2f(child.size().x(), 0.0),
-                Axis::Vertical => child_origin += vec2f(0.0, child.size().y()),
+                Axis::Horizontal => child_origin += vec2f(child.size().x() + spacing, 0.0),
+                Axis::Vertical => child_origin += vec2f(0.0, child.size().y() + spacing),
             }
         }
 

crates/search/src/search.rs 🔗

@@ -2,13 +2,13 @@ use bitflags::bitflags;
 pub use buffer_search::BufferSearchBar;
 use gpui::{
     actions,
-    elements::{Component, StyleableComponent, TooltipStyle},
+    elements::{Component, SafeStylable, TooltipStyle},
     Action, AnyElement, AppContext, Element, View,
 };
 pub use mode::SearchMode;
 use project::search::SearchQuery;
 pub use project_search::{ProjectSearchBar, ProjectSearchView};
-use theme::components::{action_button::ActionButton, ComponentExt, ToggleIconButtonStyle};
+use theme::components::{action_button::Button, svg::Svg, ComponentExt, ToggleIconButtonStyle};
 
 pub mod buffer_search;
 mod history;
@@ -89,15 +89,12 @@ impl SearchOptions {
         tooltip_style: TooltipStyle,
         button_style: ToggleIconButtonStyle,
     ) -> AnyElement<V> {
-        ActionButton::new_dynamic(
-            self.to_toggle_action(),
-            format!("Toggle {}", self.label()),
-            tooltip_style,
-        )
-        .with_contents(theme::components::svg::Svg::new(self.icon()))
-        .toggleable(active)
-        .with_style(button_style)
-        .element()
-        .into_any()
+        Button::dynamic_action(self.to_toggle_action())
+            .with_tooltip(format!("Toggle {}", self.label()), tooltip_style)
+            .with_contents(Svg::new(self.icon()))
+            .toggleable(active)
+            .with_style(button_style)
+            .element()
+            .into_any()
     }
 }

crates/theme/src/components.rs 🔗

@@ -1,23 +1,143 @@
-use gpui::elements::StyleableComponent;
+use gpui::{elements::SafeStylable, Action};
 
 use crate::{Interactive, Toggleable};
 
-use self::{action_button::ButtonStyle, svg::SvgStyle, toggle::Toggle};
+use self::{action_button::ButtonStyle, disclosure::Disclosable, svg::SvgStyle, toggle::Toggle};
 
-pub type ToggleIconButtonStyle = Toggleable<Interactive<ButtonStyle<SvgStyle>>>;
+pub type IconButtonStyle = Interactive<ButtonStyle<SvgStyle>>;
+pub type ToggleIconButtonStyle = Toggleable<IconButtonStyle>;
 
-pub trait ComponentExt<C: StyleableComponent> {
+pub trait ComponentExt<C: SafeStylable> {
     fn toggleable(self, active: bool) -> Toggle<C, ()>;
+    fn disclosable(self, disclosed: Option<bool>, action: Box<dyn Action>) -> Disclosable<C, ()>;
 }
 
-impl<C: StyleableComponent> ComponentExt<C> for C {
+impl<C: SafeStylable> ComponentExt<C> for C {
     fn toggleable(self, active: bool) -> Toggle<C, ()> {
         Toggle::new(self, active)
     }
+
+    /// Some(True) => disclosed => content is visible
+    /// Some(false) => closed => content is hidden
+    /// None => No disclosure button, but reserve disclosure spacing
+    fn disclosable(self, disclosed: Option<bool>, action: Box<dyn Action>) -> Disclosable<C, ()> {
+        Disclosable::new(disclosed, self, action)
+    }
+}
+
+pub mod disclosure {
+
+    use gpui::{
+        elements::{Component, ContainerStyle, Empty, Flex, ParentElement, SafeStylable},
+        Action, Element,
+    };
+    use schemars::JsonSchema;
+    use serde_derive::Deserialize;
+
+    use super::{action_button::Button, svg::Svg, IconButtonStyle};
+
+    #[derive(Clone, Default, Deserialize, JsonSchema)]
+    pub struct DisclosureStyle<S> {
+        pub button: IconButtonStyle,
+        #[serde(flatten)]
+        pub container: ContainerStyle,
+        pub spacing: f32,
+        #[serde(flatten)]
+        content: S,
+    }
+
+    impl<S> DisclosureStyle<S> {
+        pub fn button_space(&self) -> f32 {
+            self.spacing + self.button.button_width.unwrap()
+        }
+    }
+
+    pub struct Disclosable<C, S> {
+        disclosed: Option<bool>,
+        action: Box<dyn Action>,
+        id: usize,
+        content: C,
+        style: S,
+    }
+
+    impl Disclosable<(), ()> {
+        pub fn new<C>(
+            disclosed: Option<bool>,
+            content: C,
+            action: Box<dyn Action>,
+        ) -> Disclosable<C, ()> {
+            Disclosable {
+                disclosed,
+                content,
+                action,
+                id: 0,
+                style: (),
+            }
+        }
+    }
+
+    impl<C> Disclosable<C, ()> {
+        pub fn with_id(mut self, id: usize) -> Disclosable<C, ()> {
+            self.id = id;
+            self
+        }
+    }
+
+    impl<C: SafeStylable> SafeStylable for Disclosable<C, ()> {
+        type Style = DisclosureStyle<C::Style>;
+
+        type Output = Disclosable<C, Self::Style>;
+
+        fn with_style(self, style: Self::Style) -> Self::Output {
+            Disclosable {
+                disclosed: self.disclosed,
+                action: self.action,
+                content: self.content,
+                id: self.id,
+                style,
+            }
+        }
+    }
+
+    impl<C: SafeStylable> Component for Disclosable<C, DisclosureStyle<C::Style>> {
+        fn render<V: 'static>(self, cx: &mut gpui::ViewContext<V>) -> gpui::AnyElement<V> {
+            Flex::row()
+                .with_spacing(self.style.spacing)
+                .with_child(if let Some(disclosed) = self.disclosed {
+                    Button::dynamic_action(self.action)
+                        .with_id(self.id)
+                        .with_contents(Svg::new(if disclosed {
+                            "icons/file_icons/chevron_down.svg"
+                        } else {
+                            "icons/file_icons/chevron_right.svg"
+                        }))
+                        .with_style(self.style.button)
+                        .element()
+                        .into_any()
+                } else {
+                    Empty::new()
+                        .into_any()
+                        .constrained()
+                        // TODO: Why is this optional at all?
+                        .with_width(self.style.button.button_width.unwrap())
+                        .into_any()
+                })
+                .with_child(
+                    self.content
+                        .with_style(self.style.content)
+                        .render(cx)
+                        .flex(1., true),
+                )
+                .align_children_center()
+                .contained()
+                .with_style(self.style.container)
+                .into_any()
+        }
+    }
 }
 
 pub mod toggle {
-    use gpui::elements::{GeneralComponent, StyleableComponent};
+    use gpui::elements::{Component, SafeStylable};
 
     use crate::Toggleable;
 
@@ -27,7 +147,7 @@ pub mod toggle {
         component: C,
     }
 
-    impl<C: StyleableComponent> Toggle<C, ()> {
+    impl<C: SafeStylable> Toggle<C, ()> {
         pub fn new(component: C, active: bool) -> Self {
             Toggle {
                 active,
@@ -37,7 +157,7 @@ pub mod toggle {
         }
     }
 
-    impl<C: StyleableComponent> StyleableComponent for Toggle<C, ()> {
+    impl<C: SafeStylable> SafeStylable for Toggle<C, ()> {
         type Style = Toggleable<C::Style>;
 
         type Output = Toggle<C, Self::Style>;
@@ -51,15 +171,11 @@ pub mod toggle {
         }
     }
 
-    impl<C: StyleableComponent> GeneralComponent for Toggle<C, Toggleable<C::Style>> {
-        fn render<V: gpui::View>(
-            self,
-            v: &mut V,
-            cx: &mut gpui::ViewContext<V>,
-        ) -> gpui::AnyElement<V> {
+    impl<C: SafeStylable> Component for Toggle<C, Toggleable<C::Style>> {
+        fn render<V: 'static>(self, cx: &mut gpui::ViewContext<V>) -> gpui::AnyElement<V> {
             self.component
                 .with_style(self.style.in_state(self.active).clone())
-                .render(v, cx)
+                .render(cx)
         }
     }
 }
@@ -68,96 +184,103 @@ pub mod action_button {
     use std::borrow::Cow;
 
     use gpui::{
-        elements::{
-            ContainerStyle, GeneralComponent, MouseEventHandler, StyleableComponent, TooltipStyle,
-        },
+        elements::{Component, ContainerStyle, MouseEventHandler, SafeStylable, TooltipStyle},
         platform::{CursorStyle, MouseButton},
-        Action, Element, TypeTag, View,
+        Action, Element, TypeTag,
     };
     use schemars::JsonSchema;
     use serde_derive::Deserialize;
 
     use crate::Interactive;
 
-    pub struct ActionButton<C, S> {
-        action: Box<dyn Action>,
-        tooltip: Cow<'static, str>,
-        tooltip_style: TooltipStyle,
-        tag: TypeTag,
-        contents: C,
-        style: Interactive<S>,
-    }
-
     #[derive(Clone, Deserialize, Default, JsonSchema)]
     pub struct ButtonStyle<C> {
         #[serde(flatten)]
-        container: ContainerStyle,
-        button_width: Option<f32>,
-        button_height: Option<f32>,
+        pub container: ContainerStyle,
+        // TODO: These are incorrect for the intended usage of the buttons.
+        // The size should be constant, but putting them here duplicates them
+        // across the states the buttons can be in
+        pub button_width: Option<f32>,
+        pub button_height: Option<f32>,
         #[serde(flatten)]
         contents: C,
     }
 
-    impl ActionButton<(), ()> {
-        pub fn new_dynamic(
-            action: Box<dyn Action>,
-            tooltip: impl Into<Cow<'static, str>>,
-            tooltip_style: TooltipStyle,
-        ) -> Self {
+    pub struct Button<C, S> {
+        action: Box<dyn Action>,
+        tooltip: Option<(Cow<'static, str>, TooltipStyle)>,
+        tag: TypeTag,
+        id: usize,
+        contents: C,
+        style: Interactive<S>,
+    }
+
+    impl Button<(), ()> {
+        pub fn dynamic_action(action: Box<dyn Action>) -> Button<(), ()> {
             Self {
                 contents: (),
                 tag: action.type_tag(),
-                style: Interactive::new_blank(),
-                tooltip: tooltip.into(),
-                tooltip_style,
                 action,
+                style: Interactive::new_blank(),
+                tooltip: None,
+                id: 0,
             }
         }
 
-        pub fn new<A: Action + Clone>(
-            action: A,
+        pub fn action<A: Action + Clone>(action: A) -> Self {
+            Self::dynamic_action(Box::new(action))
+        }
+
+        pub fn with_tooltip(
+            mut self,
             tooltip: impl Into<Cow<'static, str>>,
             tooltip_style: TooltipStyle,
         ) -> Self {
-            Self::new_dynamic(Box::new(action), tooltip, tooltip_style)
+            self.tooltip = Some((tooltip.into(), tooltip_style));
+            self
+        }
+
+        pub fn with_id(mut self, id: usize) -> Self {
+            self.id = id;
+            self
         }
 
-        pub fn with_contents<C: StyleableComponent>(self, contents: C) -> ActionButton<C, ()> {
-            ActionButton {
+        pub fn with_contents<C: SafeStylable>(self, contents: C) -> Button<C, ()> {
+            Button {
                 action: self.action,
                 tag: self.tag,
                 style: self.style,
                 tooltip: self.tooltip,
-                tooltip_style: self.tooltip_style,
+                id: self.id,
                 contents,
             }
         }
     }
 
-    impl<C: StyleableComponent> StyleableComponent for ActionButton<C, ()> {
+    impl<C: SafeStylable> SafeStylable for Button<C, ()> {
         type Style = Interactive<ButtonStyle<C::Style>>;
-        type Output = ActionButton<C, ButtonStyle<C::Style>>;
+        type Output = Button<C, ButtonStyle<C::Style>>;
 
         fn with_style(self, style: Self::Style) -> Self::Output {
-            ActionButton {
+            Button {
                 action: self.action,
                 tag: self.tag,
                 contents: self.contents,
                 tooltip: self.tooltip,
-                tooltip_style: self.tooltip_style,
+                id: self.id,
                 style,
             }
         }
     }
 
-    impl<C: StyleableComponent> GeneralComponent for ActionButton<C, ButtonStyle<C::Style>> {
-        fn render<V: View>(self, v: &mut V, cx: &mut gpui::ViewContext<V>) -> gpui::AnyElement<V> {
-            MouseEventHandler::new_dynamic(self.tag, 0, cx, |state, cx| {
+    impl<C: SafeStylable> Component for Button<C, ButtonStyle<C::Style>> {
+        fn render<V: 'static>(self, cx: &mut gpui::ViewContext<V>) -> gpui::AnyElement<V> {
+            let mut button = MouseEventHandler::new_dynamic(self.tag, self.id, cx, |state, cx| {
                 let style = self.style.style_for(state);
                 let mut contents = self
                     .contents
                     .with_style(style.contents.to_owned())
-                    .render(v, cx)
+                    .render(cx)
                     .contained()
                     .with_style(style.container)
                     .constrained();
@@ -185,15 +308,15 @@ pub mod action_button {
                 }
             })
             .with_cursor_style(CursorStyle::PointingHand)
-            .with_dynamic_tooltip(
-                self.tag,
-                0,
-                self.tooltip,
-                Some(self.action),
-                self.tooltip_style,
-                cx,
-            )
-            .into_any()
+            .into_any();
+
+            if let Some((tooltip, style)) = self.tooltip {
+                button = button
+                    .with_dynamic_tooltip(self.tag, 0, tooltip, Some(self.action), style, cx)
+                    .into_any()
+            }
+
+            button
         }
     }
 }
@@ -202,7 +325,7 @@ pub mod svg {
     use std::borrow::Cow;
 
     use gpui::{
-        elements::{GeneralComponent, StyleableComponent},
+        elements::{Component, Empty, SafeStylable},
         Element,
     };
     use schemars::JsonSchema;
@@ -225,6 +348,7 @@ pub mod svg {
             pub enum IconSize {
                 IconSize { icon_size: f32 },
                 Dimensions { width: f32, height: f32 },
+                IconDimensions { icon_width: f32, icon_height: f32 },
             }
 
             #[derive(Deserialize)]
@@ -248,6 +372,14 @@ pub mod svg {
                     icon_height: height,
                     color,
                 },
+                IconSize::IconDimensions {
+                    icon_width,
+                    icon_height,
+                } => SvgStyle {
+                    icon_width,
+                    icon_height,
+                    color,
+                },
             };
 
             Ok(result)
@@ -255,20 +387,27 @@ pub mod svg {
     }
 
     pub struct Svg<S> {
-        path: Cow<'static, str>,
+        path: Option<Cow<'static, str>>,
         style: S,
     }
 
     impl Svg<()> {
         pub fn new(path: impl Into<Cow<'static, str>>) -> Self {
             Self {
-                path: path.into(),
+                path: Some(path.into()),
+                style: (),
+            }
+        }
+
+        pub fn optional(path: Option<impl Into<Cow<'static, str>>>) -> Self {
+            Self {
+                path: path.map(Into::into),
                 style: (),
             }
         }
     }
 
-    impl StyleableComponent for Svg<()> {
+    impl SafeStylable for Svg<()> {
         type Style = SvgStyle;
 
         type Output = Svg<SvgStyle>;
@@ -281,18 +420,19 @@ pub mod svg {
         }
     }
 
-    impl GeneralComponent for Svg<SvgStyle> {
-        fn render<V: gpui::View>(
-            self,
-            _: &mut V,
-            _: &mut gpui::ViewContext<V>,
-        ) -> gpui::AnyElement<V> {
-            gpui::elements::Svg::new(self.path)
-                .with_color(self.style.color)
-                .constrained()
-                .with_width(self.style.icon_width)
-                .with_height(self.style.icon_height)
-                .into_any()
+    impl Component for Svg<SvgStyle> {
+        fn render<V: 'static>(self, _: &mut gpui::ViewContext<V>) -> gpui::AnyElement<V> {
+            if let Some(path) = self.path {
+                gpui::elements::Svg::new(path)
+                    .with_color(self.style.color)
+                    .constrained()
+            } else {
+                Empty::new().constrained()
+            }
+            .constrained()
+            .with_width(self.style.icon_width)
+            .with_height(self.style.icon_height)
+            .into_any()
         }
     }
 }
@@ -301,7 +441,8 @@ pub mod label {
     use std::borrow::Cow;
 
     use gpui::{
-        elements::{GeneralComponent, LabelStyle, StyleableComponent},
+        elements::{Component, LabelStyle, SafeStylable},
+        fonts::TextStyle,
         Element,
     };
 
@@ -319,25 +460,21 @@ pub mod label {
         }
     }
 
-    impl StyleableComponent for Label<()> {
-        type Style = LabelStyle;
+    impl SafeStylable for Label<()> {
+        type Style = TextStyle;
 
         type Output = Label<LabelStyle>;
 
         fn with_style(self, style: Self::Style) -> Self::Output {
             Label {
                 text: self.text,
-                style,
+                style: style.into(),
             }
         }
     }
 
-    impl GeneralComponent for Label<LabelStyle> {
-        fn render<V: gpui::View>(
-            self,
-            _: &mut V,
-            _: &mut gpui::ViewContext<V>,
-        ) -> gpui::AnyElement<V> {
+    impl Component for Label<LabelStyle> {
+        fn render<V: 'static>(self, _: &mut gpui::ViewContext<V>) -> gpui::AnyElement<V> {
             gpui::elements::Label::new(self.text, self.style).into_any()
         }
     }

crates/theme/src/theme.rs 🔗

@@ -3,7 +3,7 @@ mod theme_registry;
 mod theme_settings;
 pub mod ui;
 
-use components::ToggleIconButtonStyle;
+use components::{action_button::ButtonStyle, disclosure::DisclosureStyle, ToggleIconButtonStyle};
 use gpui::{
     color::Color,
     elements::{ContainerStyle, ImageStyle, LabelStyle, Shadow, SvgStyle, TooltipStyle},
@@ -14,7 +14,7 @@ use schemars::JsonSchema;
 use serde::{de::DeserializeOwned, Deserialize};
 use serde_json::Value;
 use settings::SettingsStore;
-use std::{collections::HashMap, sync::Arc};
+use std::{collections::HashMap, ops::Deref, sync::Arc};
 use ui::{CheckboxStyle, CopilotCTAButton, IconStyle, ModalStyle};
 
 pub use theme_registry::*;
@@ -66,6 +66,7 @@ pub struct Theme {
     pub feedback: FeedbackStyle,
     pub welcome: WelcomeStyle,
     pub titlebar: Titlebar,
+    pub component_test: ComponentTest,
 }
 
 #[derive(Deserialize, Default, Clone, JsonSchema)]
@@ -221,6 +222,7 @@ pub struct CopilotAuthAuthorized {
 pub struct CollabPanel {
     #[serde(flatten)]
     pub container: ContainerStyle,
+    pub disclosure: DisclosureStyle<()>,
     pub list_empty_state: Toggleable<Interactive<ContainedText>>,
     pub list_empty_icon: Icon,
     pub list_empty_label_container: ContainerStyle,
@@ -259,6 +261,13 @@ pub struct CollabPanel {
     pub face_overlap: f32,
 }
 
+#[derive(Deserialize, Default, JsonSchema)]
+pub struct ComponentTest {
+    pub button: Interactive<ButtonStyle<TextStyle>>,
+    pub toggle: Toggleable<Interactive<ButtonStyle<TextStyle>>>,
+    pub disclosure: DisclosureStyle<TextStyle>,
+}
+
 #[derive(Deserialize, Default, JsonSchema)]
 pub struct TabbedModal {
     pub tab_button: Toggleable<Interactive<ContainedText>>,
@@ -890,6 +899,14 @@ pub struct Interactive<T> {
     pub disabled: Option<T>,
 }
 
+impl<T> Deref for Interactive<T> {
+    type Target = T;
+
+    fn deref(&self) -> &Self::Target {
+        &self.default
+    }
+}
+
 impl Interactive<()> {
     pub fn new_blank() -> Self {
         Self {
@@ -907,6 +924,14 @@ pub struct Toggleable<T> {
     inactive: T,
 }
 
+impl<T> Deref for Toggleable<T> {
+    type Target = T;
+
+    fn deref(&self) -> &Self::Target {
+        &self.inactive
+    }
+}
+
 impl Toggleable<()> {
     pub fn new_blank() -> Self {
         Self {

crates/zed/Cargo.toml 🔗

@@ -25,6 +25,7 @@ cli = { path = "../cli" }
 collab_ui = { path = "../collab_ui" }
 collections = { path = "../collections" }
 command_palette = { path = "../command_palette" }
+component_test = { path = "../component_test" }
 context_menu = { path = "../context_menu" }
 client = { path = "../client" }
 clock = { path = "../clock" }

crates/zed/src/main.rs 🔗

@@ -166,6 +166,7 @@ fn main() {
         terminal_view::init(cx);
         copilot::init(http.clone(), node_runtime, cx);
         ai::init(cx);
+        component_test::init(cx);
 
         cx.spawn(|cx| watch_themes(fs.clone(), cx)).detach();
         cx.spawn(|_| watch_languages(fs.clone(), languages.clone()))

styles/src/component/icon_button.ts 🔗

@@ -44,10 +44,10 @@ export function icon_button({ color, margin, layer, variant, size }: IconButtonO
     }
 
     const padding = {
-        top: size === Button.size.Small ? 0 : 2,
-        bottom: size === Button.size.Small ? 0 : 2,
-        left: size === Button.size.Small ? 0 : 4,
-        right: size === Button.size.Small ? 0 : 4,
+        top: size === Button.size.Small ? 2 : 2,
+        bottom: size === Button.size.Small ? 2 : 2,
+        left: size === Button.size.Small ? 2 : 4,
+        right: size === Button.size.Small ? 2 : 4,
     }
 
     return interactive({
@@ -55,10 +55,10 @@ export function icon_button({ color, margin, layer, variant, size }: IconButtonO
             corner_radius: 6,
             padding: padding,
             margin: m,
-            icon_width: 14,
+            icon_width: 12,
             icon_height: 14,
-            button_width: 20,
-            button_height: 16,
+            button_width: size === Button.size.Small ? 16 : 20,
+            button_height: 14,
         },
         state: {
             default: {

styles/src/style_tree/app.ts 🔗

@@ -12,7 +12,6 @@ import simple_message_notification from "./simple_message_notification"
 import project_shared_notification from "./project_shared_notification"
 import tooltip from "./tooltip"
 import terminal from "./terminal"
-import contact_finder from "./contact_finder"
 import collab_panel from "./collab_panel"
 import toolbar_dropdown_menu from "./toolbar_dropdown_menu"
 import incoming_call_notification from "./incoming_call_notification"
@@ -22,6 +21,7 @@ import assistant from "./assistant"
 import { titlebar } from "./titlebar"
 import editor from "./editor"
 import feedback from "./feedback"
+import component_test from "./component_test"
 import { useTheme } from "../common"
 
 export default function app(): any {
@@ -54,6 +54,7 @@ export default function app(): any {
         tooltip: tooltip(),
         terminal: terminal(),
         assistant: assistant(),
-        feedback: feedback()
+        feedback: feedback(),
+        component_test: component_test(),
     }
 }

styles/src/style_tree/collab_panel.ts 🔗

@@ -14,6 +14,7 @@ import { indicator } from "../component/indicator"
 export default function contacts_panel(): any {
     const theme = useTheme()
 
+    const CHANNEL_SPACING = 4 as const
     const NAME_MARGIN = 6 as const
     const SPACING = 12 as const
     const INDENT_SIZE = 8 as const
@@ -152,6 +153,10 @@ export default function contacts_panel(): any {
 
     return {
         ...collab_modals(),
+        disclosure: {
+            button: icon_button({ variant: "ghost", size: "sm" }),
+            spacing: CHANNEL_SPACING,
+        },
         log_in_button: interactive({
             base: {
                 background: background(theme.middle),
@@ -194,7 +199,7 @@ export default function contacts_panel(): any {
         add_channel_button: header_icon_button,
         leave_call_button: header_icon_button,
         row_height: ITEM_HEIGHT,
-        channel_indent: INDENT_SIZE * 2,
+        channel_indent: INDENT_SIZE * 2 + 2,
         section_icon_size: 14,
         header_row: {
             ...text(layer, "sans", { size: "sm", weight: "bold" }),
@@ -264,7 +269,7 @@ export default function contacts_panel(): any {
         channel_name: {
             ...text(layer, "sans", { size: "sm" }),
             margin: {
-                left: NAME_MARGIN,
+                left: CHANNEL_SPACING,
             },
         },
         list_empty_label_container: {

styles/src/style_tree/component_test.ts 🔗

@@ -0,0 +1,27 @@
+
+import { useTheme } from "../common"
+import { text_button } from "../component/text_button"
+import { icon_button } from "../component/icon_button"
+import { text } from "./components"
+import { toggleable } from "../element"
+
+export default function contacts_panel(): any {
+    const theme = useTheme()
+
+    return {
+        button: text_button({}),
+        toggle: toggleable({
+            base: text_button({}),
+            state: {
+                active: {
+                    ...text_button({ color: "accent" })
+                }
+            }
+        }),
+        disclosure: {
+            ...text(theme.lowest, "sans", "base"),
+            button: icon_button({ variant: "ghost" }),
+            spacing: 4,
+        }
+    }
+}