Use `ListItem`s in the project panel (#3421)

Marshall Bowers created

This PR reworks the project panel to render its items using the
`ListItem` component.

There are a few hacks in here in order to get click handlers working for
the `ListItem`, but we'll want to get these fixed in GPUI.

Release Notes:

- N/A

Change summary

crates/project_panel2/src/project_panel.rs     |  66 +++------
crates/ui2/src/components/list.rs              | 142 +++++++------------
crates/ui2/src/components/stories/list_item.rs |   8 +
3 files changed, 84 insertions(+), 132 deletions(-)

Detailed changes

crates/project_panel2/src/project_panel.rs 🔗

@@ -10,9 +10,8 @@ use anyhow::{anyhow, Result};
 use gpui::{
     actions, div, px, uniform_list, Action, AppContext, AssetSource, AsyncWindowContext,
     ClipboardItem, Div, EventEmitter, FocusHandle, Focusable, FocusableView, InteractiveElement,
-    IntoElement, Model, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, PromptLevel,
-    Render, Stateful, StatefulInteractiveElement, Styled, Task, UniformListScrollHandle, View,
-    ViewContext, VisualContext as _, WeakView, WindowContext,
+    Model, MouseDownEvent, ParentElement, Pixels, Point, PromptLevel, Render, Stateful, Styled,
+    Task, UniformListScrollHandle, View, ViewContext, VisualContext as _, WeakView, WindowContext,
 };
 use menu::{Confirm, SelectNext, SelectPrev};
 use project::{
@@ -30,7 +29,7 @@ use std::{
     sync::Arc,
 };
 use theme::ActiveTheme as _;
-use ui::{h_stack, v_stack, IconElement, Label};
+use ui::{v_stack, IconElement, Label, ListItem};
 use unicase::UniCase;
 use util::{maybe, ResultExt, TryFutureExt};
 use workspace::{
@@ -1335,13 +1334,19 @@ impl ProjectPanel {
         }
     }
 
-    fn render_entry_visual_element(
-        details: &EntryDetails,
-        editor: Option<&View<Editor>>,
-        padding: Pixels,
+    fn render_entry(
+        &self,
+        entry_id: ProjectEntryId,
+        details: EntryDetails,
+        // dragged_entry_destination: &mut Option<Arc<Path>>,
         cx: &mut ViewContext<Self>,
-    ) -> Div {
+    ) -> ListItem {
+        let kind = details.kind;
+        let settings = ProjectPanelSettings::get_global(cx);
         let show_editor = details.is_editing && !details.is_processing;
+        let is_selected = self
+            .selection
+            .map_or(false, |selection| selection.entry_id == entry_id);
 
         let theme = cx.theme();
         let filename_text_color = details
@@ -1354,14 +1359,17 @@ impl ProjectPanel {
             })
             .unwrap_or(theme.status().info);
 
-        h_stack()
+        ListItem::new(entry_id.to_proto() as usize)
+            .indent_level(details.depth)
+            .indent_step_size(px(settings.indent_size))
+            .selected(is_selected)
             .child(if let Some(icon) = &details.icon {
                 div().child(IconElement::from_path(icon.to_string()))
             } else {
                 div()
             })
             .child(
-                if let (Some(editor), true) = (editor, show_editor) {
+                if let (Some(editor), true) = (Some(&self.filename_editor), show_editor) {
                     div().w_full().child(editor.clone())
                 } else {
                     div()
@@ -1370,33 +1378,6 @@ impl ProjectPanel {
                 }
                 .ml_1(),
             )
-            .pl(padding)
-    }
-
-    fn render_entry(
-        &self,
-        entry_id: ProjectEntryId,
-        details: EntryDetails,
-        // dragged_entry_destination: &mut Option<Arc<Path>>,
-        cx: &mut ViewContext<Self>,
-    ) -> Stateful<Div> {
-        let kind = details.kind;
-        let settings = ProjectPanelSettings::get_global(cx);
-        const INDENT_SIZE: Pixels = px(16.0);
-        let padding = INDENT_SIZE + details.depth as f32 * px(settings.indent_size);
-        let show_editor = details.is_editing && !details.is_processing;
-        let is_selected = self
-            .selection
-            .map_or(false, |selection| selection.entry_id == entry_id);
-
-        Self::render_entry_visual_element(&details, Some(&self.filename_editor), padding, cx)
-            .id(entry_id.to_proto() as usize)
-            .w_full()
-            .cursor_pointer()
-            .when(is_selected, |this| {
-                this.bg(cx.theme().colors().element_selected)
-            })
-            .hover(|style| style.bg(cx.theme().colors().element_hover))
             .on_click(cx.listener(move |this, event: &gpui::ClickEvent, cx| {
                 if !show_editor {
                     if kind.is_dir() {
@@ -1410,12 +1391,9 @@ impl ProjectPanel {
                     }
                 }
             }))
-            .on_mouse_down(
-                MouseButton::Right,
-                cx.listener(move |this, event: &MouseDownEvent, cx| {
-                    this.deploy_context_menu(event.position, entry_id, cx);
-                }),
-            )
+            .on_secondary_mouse_down(cx.listener(move |this, event: &MouseDownEvent, cx| {
+                this.deploy_context_menu(event.position, entry_id, cx);
+            }))
         // .on_drop::<ProjectEntryId>(|this, event, cx| {
         //     this.move_entry(
         //         *dragged_entry,

crates/ui2/src/components/list.rs 🔗

@@ -1,8 +1,10 @@
+use std::rc::Rc;
+
 use gpui::{
-    div, px, AnyElement, ClickEvent, Div, IntoElement, Stateful, StatefulInteractiveElement,
+    div, px, AnyElement, ClickEvent, Div, IntoElement, MouseButton, MouseDownEvent, Pixels,
+    Stateful, StatefulInteractiveElement,
 };
 use smallvec::SmallVec;
-use std::rc::Rc;
 
 use crate::{
     disclosure_control, h_stack, v_stack, Avatar, Icon, IconElement, IconSize, Label, Toggle,
@@ -117,66 +119,6 @@ impl ListHeader {
         self.meta = meta;
         self
     }
-
-    // before_ship!("delete")
-    // fn render<V: 'static>(self,  cx: &mut WindowContext) -> impl Element<V> {
-    //     let disclosure_control = disclosure_control(self.toggle);
-
-    //     let meta = match self.meta {
-    //         Some(ListHeaderMeta::Tools(icons)) => div().child(
-    //             h_stack()
-    //                 .gap_2()
-    //                 .items_center()
-    //                 .children(icons.into_iter().map(|i| {
-    //                     IconElement::new(i)
-    //                         .color(TextColor::Muted)
-    //                         .size(IconSize::Small)
-    //                 })),
-    //         ),
-    //         Some(ListHeaderMeta::Button(label)) => div().child(label),
-    //         Some(ListHeaderMeta::Text(label)) => div().child(label),
-    //         None => div(),
-    //     };
-
-    //     h_stack()
-    //         .w_full()
-    //         .bg(cx.theme().colors().surface_background)
-    //         // TODO: Add focus state
-    //         // .when(self.state == InteractionState::Focused, |this| {
-    //         //     this.border()
-    //         //         .border_color(cx.theme().colors().border_focused)
-    //         // })
-    //         .relative()
-    //         .child(
-    //             div()
-    //                 .h_5()
-    //                 .when(self.variant == ListItemVariant::Inset, |this| this.px_2())
-    //                 .flex()
-    //                 .flex_1()
-    //                 .items_center()
-    //                 .justify_between()
-    //                 .w_full()
-    //                 .gap_1()
-    //                 .child(
-    //                     h_stack()
-    //                         .gap_1()
-    //                         .child(
-    //                             div()
-    //                                 .flex()
-    //                                 .gap_1()
-    //                                 .items_center()
-    //                                 .children(self.left_icon.map(|i| {
-    //                                     IconElement::new(i)
-    //                                         .color(TextColor::Muted)
-    //                                         .size(IconSize::Small)
-    //                                 }))
-    //                                 .child(Label::new(self.label.clone()).color(TextColor::Muted)),
-    //                         )
-    //                         .child(disclosure_control),
-    //                 )
-    //                 .child(meta),
-    //         )
-    // }
 }
 
 #[derive(IntoElement, Clone)]
@@ -238,12 +180,14 @@ pub struct ListItem {
     selected: bool,
     // TODO: Reintroduce this
     // disclosure_control_style: DisclosureControlVisibility,
-    indent_level: u32,
+    indent_level: usize,
+    indent_step_size: Pixels,
     left_slot: Option<GraphicSlot>,
     overflow: OverflowStyle,
     toggle: Toggle,
     variant: ListItemVariant,
     on_click: Option<Rc<dyn Fn(&ClickEvent, &mut WindowContext) + 'static>>,
+    on_secondary_mouse_down: Option<Rc<dyn Fn(&MouseDownEvent, &mut WindowContext) + 'static>>,
     children: SmallVec<[AnyElement; 2]>,
 }
 
@@ -254,11 +198,13 @@ impl ListItem {
             disabled: false,
             selected: false,
             indent_level: 0,
+            indent_step_size: px(12.),
             left_slot: None,
             overflow: OverflowStyle::Hidden,
             toggle: Toggle::NotToggleable,
             variant: ListItemVariant::default(),
-            on_click: Default::default(),
+            on_click: None,
+            on_secondary_mouse_down: None,
             children: SmallVec::new(),
         }
     }
@@ -268,16 +214,29 @@ impl ListItem {
         self
     }
 
+    pub fn on_secondary_mouse_down(
+        mut self,
+        handler: impl Fn(&MouseDownEvent, &mut WindowContext) + 'static,
+    ) -> Self {
+        self.on_secondary_mouse_down = Some(Rc::new(handler));
+        self
+    }
+
     pub fn variant(mut self, variant: ListItemVariant) -> Self {
         self.variant = variant;
         self
     }
 
-    pub fn indent_level(mut self, indent_level: u32) -> Self {
+    pub fn indent_level(mut self, indent_level: usize) -> Self {
         self.indent_level = indent_level;
         self
     }
 
+    pub fn indent_step_size(mut self, indent_step_size: Pixels) -> Self {
+        self.indent_step_size = indent_step_size;
+        self
+    }
+
     pub fn toggle(mut self, toggle: Toggle) -> Self {
         self.toggle = toggle;
         self
@@ -328,14 +287,6 @@ impl RenderOnce for ListItem {
                 style.background = Some(cx.theme().colors().editor_background.into());
                 style
             })
-            .on_click({
-                let on_click = self.on_click.clone();
-                move |event, cx| {
-                    if let Some(on_click) = &on_click {
-                        (on_click)(event, cx)
-                    }
-                }
-            })
             // TODO: Add focus state
             // .when(self.state == InteractionState::Focused, |this| {
             //     this.border()
@@ -346,30 +297,45 @@ impl RenderOnce for ListItem {
             .when(self.selected, |this| {
                 this.bg(cx.theme().colors().ghost_element_selected)
             })
+            .when_some(self.on_click.clone(), |this, on_click| {
+                this.on_click(move |event, cx| {
+                    // HACK: GPUI currently fires `on_click` with any mouse button,
+                    // but we only care about the left button.
+                    if event.down.button == MouseButton::Left {
+                        (on_click)(event, cx)
+                    }
+                })
+            })
+            .when_some(self.on_secondary_mouse_down, |this, on_mouse_down| {
+                this.on_mouse_down(MouseButton::Right, move |event, cx| {
+                    (on_mouse_down)(event, cx)
+                })
+            })
             .child(
                 div()
                     .when(self.variant == ListItemVariant::Inset, |this| this.px_2())
-                    // .ml(rems(0.75 * self.indent_level as f32))
-                    .children((0..self.indent_level).map(|_| {
-                        div()
-                            .w(px(4.))
-                            .h_full()
-                            .flex()
-                            .justify_center()
-                            .group_hover("", |style| style.bg(cx.theme().colors().border_focused))
-                            .child(
-                                h_stack()
-                                    .child(div().w_px().h_full())
-                                    .child(div().w_px().h_full().bg(cx.theme().colors().border)),
-                            )
-                    }))
+                    .ml(self.indent_level as f32 * self.indent_step_size)
                     .flex()
                     .gap_1()
                     .items_center()
                     .relative()
                     .child(disclosure_control(self.toggle))
                     .children(left_content)
-                    .children(self.children),
+                    .children(self.children)
+                    // HACK: We need to attach the `on_click` handler to the child element in order to have the click
+                    // event actually fire.
+                    // Once this is fixed in GPUI we can remove this and rely on the `on_click` handler set above on the
+                    // outer `div`.
+                    .id("on_click_hack")
+                    .when_some(self.on_click, |this, on_click| {
+                        this.on_click(move |event, cx| {
+                            // HACK: GPUI currently fires `on_click` with any mouse button,
+                            // but we only care about the left button.
+                            if event.down.button == MouseButton::Left {
+                                (on_click)(event, cx)
+                            }
+                        })
+                    }),
             )
     }
 }

crates/ui2/src/components/stories/list_item.rs 🔗

@@ -22,5 +22,13 @@ impl Render for ListItemStory {
                         println!("Clicked!");
                     }),
             )
+            .child(Story::label("With `on_secondary_mouse_down`"))
+            .child(
+                ListItem::new("with_on_secondary_mouse_down").on_secondary_mouse_down(
+                    |_event, _cx| {
+                        println!("Right mouse down!");
+                    },
+                ),
+            )
     }
 }