project panel: Add indent guides for sticky items (#34092)

Smit Barmase created

- Adds new trait `StickyItemsDecoration` in `sticky_items` which is
implemented by `IndentGuides` from `indent_guides`.

<img width="347" alt="image"
src="https://github.com/user-attachments/assets/577748bc-13f6-41b8-9266-6a0b72349a18"
/>

Release Notes:

- N/A

Change summary

crates/gpui/src/elements/uniform_list.rs      |  29 -
crates/outline_panel/src/outline_panel.rs     |  91 ++--
crates/project_panel/src/project_panel.rs     | 153 ++++--
crates/storybook/src/stories/indent_guides.rs |  38 
crates/ui/src/components/indent_guides.rs     | 451 +++++++++++---------
crates/ui/src/components/sticky_items.rs      | 214 ++++++++-
6 files changed, 578 insertions(+), 398 deletions(-)

Detailed changes

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

@@ -506,35 +506,6 @@ pub trait UniformListDecoration {
     ) -> AnyElement;
 }
 
-/// A trait for implementing top slots in a [`UniformList`].
-/// Top slots are elements that appear at the top of the list and can adjust
-/// the visible range of list items.
-pub trait UniformListTopSlot {
-    /// Returns elements to render at the top slot for the given visible range.
-    fn compute(
-        &mut self,
-        visible_range: Range<usize>,
-        window: &mut Window,
-        cx: &mut App,
-    ) -> SmallVec<[AnyElement; 8]>;
-
-    /// Layout and prepaint the top slot elements.
-    fn prepaint(
-        &self,
-        elements: &mut SmallVec<[AnyElement; 8]>,
-        bounds: Bounds<Pixels>,
-        item_height: Pixels,
-        scroll_offset: Point<Pixels>,
-        padding: crate::Edges<Pixels>,
-        can_scroll_horizontally: bool,
-        window: &mut Window,
-        cx: &mut App,
-    );
-
-    /// Paint the top slot elements.
-    fn paint(&self, elements: &mut SmallVec<[AnyElement; 8]>, window: &mut Window, cx: &mut App);
-}
-
 impl UniformList {
     /// Selects a specific list item for measurement.
     pub fn with_width_from_item(mut self, item_index: Option<usize>) -> Self {

crates/outline_panel/src/outline_panel.rs 🔗

@@ -4584,53 +4584,52 @@ impl OutlinePanel {
                 .track_scroll(self.scroll_handle.clone())
                 .when(show_indent_guides, |list| {
                     list.with_decoration(
-                        ui::indent_guides(
-                            cx.entity().clone(),
-                            px(indent_size),
-                            IndentGuideColors::panel(cx),
-                            |outline_panel, range, _, _| {
-                                let entries = outline_panel.cached_entries.get(range);
-                                if let Some(entries) = entries {
-                                    entries.into_iter().map(|item| item.depth).collect()
-                                } else {
-                                    smallvec::SmallVec::new()
-                                }
-                            },
-                        )
-                        .with_render_fn(
-                            cx.entity().clone(),
-                            move |outline_panel, params, _, _| {
-                                const LEFT_OFFSET: Pixels = px(14.);
-
-                                let indent_size = params.indent_size;
-                                let item_height = params.item_height;
-                                let active_indent_guide_ix = find_active_indent_guide_ix(
-                                    outline_panel,
-                                    &params.indent_guides,
-                                );
+                        ui::indent_guides(px(indent_size), IndentGuideColors::panel(cx))
+                            .with_compute_indents_fn(
+                                cx.entity().clone(),
+                                |outline_panel, range, _, _| {
+                                    let entries = outline_panel.cached_entries.get(range);
+                                    if let Some(entries) = entries {
+                                        entries.into_iter().map(|item| item.depth).collect()
+                                    } else {
+                                        smallvec::SmallVec::new()
+                                    }
+                                },
+                            )
+                            .with_render_fn(
+                                cx.entity().clone(),
+                                move |outline_panel, params, _, _| {
+                                    const LEFT_OFFSET: Pixels = px(14.);
+
+                                    let indent_size = params.indent_size;
+                                    let item_height = params.item_height;
+                                    let active_indent_guide_ix = find_active_indent_guide_ix(
+                                        outline_panel,
+                                        &params.indent_guides,
+                                    );
 
-                                params
-                                    .indent_guides
-                                    .into_iter()
-                                    .enumerate()
-                                    .map(|(ix, layout)| {
-                                        let bounds = Bounds::new(
-                                            point(
-                                                layout.offset.x * indent_size + LEFT_OFFSET,
-                                                layout.offset.y * item_height,
-                                            ),
-                                            size(px(1.), layout.length * item_height),
-                                        );
-                                        ui::RenderedIndentGuide {
-                                            bounds,
-                                            layout,
-                                            is_active: active_indent_guide_ix == Some(ix),
-                                            hitbox: None,
-                                        }
-                                    })
-                                    .collect()
-                            },
-                        ),
+                                    params
+                                        .indent_guides
+                                        .into_iter()
+                                        .enumerate()
+                                        .map(|(ix, layout)| {
+                                            let bounds = Bounds::new(
+                                                point(
+                                                    layout.offset.x * indent_size + LEFT_OFFSET,
+                                                    layout.offset.y * item_height,
+                                                ),
+                                                size(px(1.), layout.length * item_height),
+                                            );
+                                            ui::RenderedIndentGuide {
+                                                bounds,
+                                                layout,
+                                                is_active: active_indent_guide_ix == Some(ix),
+                                                hitbox: None,
+                                            }
+                                        })
+                                        .collect()
+                                },
+                            ),
                     )
                 })
             };

crates/project_panel/src/project_panel.rs 🔗

@@ -3947,7 +3947,7 @@ impl ProjectPanel {
                 false
             }
         });
-        let shadow_color_top = hsla(0.0, 0.0, 0.0, 0.15);
+        let shadow_color_top = hsla(0.0, 0.0, 0.0, 0.1);
         let shadow_color_bottom = hsla(0.0, 0.0, 0.0, 0.);
         let sticky_shadow = div()
             .absolute()
@@ -4176,6 +4176,16 @@ impl ProjectPanel {
                         }
                     } else if kind.is_dir() {
                         this.marked_entries.clear();
+                        if is_sticky {
+                            if let Some((_, _, index)) = this.index_for_entry(entry_id, worktree_id) {
+                                let strategy = sticky_index
+                                    .map(ScrollStrategy::ToPosition)
+                                    .unwrap_or(ScrollStrategy::Top);
+                                this.scroll_handle.scroll_to_item(index, strategy);
+                                cx.notify();
+                                return;
+                            }
+                        }
                         if event.modifiers().alt {
                             this.toggle_expand_all(entry_id, window, cx);
                         } else {
@@ -4188,16 +4198,6 @@ impl ProjectPanel {
                         let allow_preview = preview_tabs_enabled && click_count == 1;
                         this.open_entry(entry_id, focus_opened_item, allow_preview, cx);
                     }
-
-                    if is_sticky {
-                        if let Some((_, _, index)) = this.index_for_entry(entry_id, worktree_id) {
-                            let strategy = sticky_index
-                                .map(ScrollStrategy::ToPosition)
-                                .unwrap_or(ScrollStrategy::Top);
-                            this.scroll_handle.scroll_to_item(index, strategy);
-                            cx.notify();
-                        }
-                    }
                 }),
             )
             .child(
@@ -5167,52 +5167,51 @@ impl Render for ProjectPanel {
                     })
                     .when(show_indent_guides, |list| {
                         list.with_decoration(
-                            ui::indent_guides(
-                                cx.entity().clone(),
-                                px(indent_size),
-                                IndentGuideColors::panel(cx),
-                                |this, range, window, cx| {
-                                    let mut items =
-                                        SmallVec::with_capacity(range.end - range.start);
-                                    this.iter_visible_entries(
-                                        range,
-                                        window,
-                                        cx,
-                                        |entry, _, entries, _, _| {
-                                            let (depth, _) = Self::calculate_depth_and_difference(
-                                                entry, entries,
-                                            );
-                                            items.push(depth);
-                                        },
-                                    );
-                                    items
-                                },
-                            )
-                            .on_click(cx.listener(
-                                |this, active_indent_guide: &IndentGuideLayout, window, cx| {
-                                    if window.modifiers().secondary() {
-                                        let ix = active_indent_guide.offset.y;
-                                        let Some((target_entry, worktree)) = maybe!({
-                                            let (worktree_id, entry) = this.entry_at_index(ix)?;
-                                            let worktree = this
-                                                .project
-                                                .read(cx)
-                                                .worktree_for_id(worktree_id, cx)?;
-                                            let target_entry = worktree
-                                                .read(cx)
-                                                .entry_for_path(&entry.path.parent()?)?;
-                                            Some((target_entry, worktree))
-                                        }) else {
-                                            return;
-                                        };
-
-                                        this.collapse_entry(target_entry.clone(), worktree, cx);
-                                    }
-                                },
-                            ))
-                            .with_render_fn(
-                                cx.entity().clone(),
-                                move |this, params, _, cx| {
+                            ui::indent_guides(px(indent_size), IndentGuideColors::panel(cx))
+                                .with_compute_indents_fn(
+                                    cx.entity().clone(),
+                                    |this, range, window, cx| {
+                                        let mut items =
+                                            SmallVec::with_capacity(range.end - range.start);
+                                        this.iter_visible_entries(
+                                            range,
+                                            window,
+                                            cx,
+                                            |entry, _, entries, _, _| {
+                                                let (depth, _) =
+                                                    Self::calculate_depth_and_difference(
+                                                        entry, entries,
+                                                    );
+                                                items.push(depth);
+                                            },
+                                        );
+                                        items
+                                    },
+                                )
+                                .on_click(cx.listener(
+                                    |this, active_indent_guide: &IndentGuideLayout, window, cx| {
+                                        if window.modifiers().secondary() {
+                                            let ix = active_indent_guide.offset.y;
+                                            let Some((target_entry, worktree)) = maybe!({
+                                                let (worktree_id, entry) =
+                                                    this.entry_at_index(ix)?;
+                                                let worktree = this
+                                                    .project
+                                                    .read(cx)
+                                                    .worktree_for_id(worktree_id, cx)?;
+                                                let target_entry = worktree
+                                                    .read(cx)
+                                                    .entry_for_path(&entry.path.parent()?)?;
+                                                Some((target_entry, worktree))
+                                            }) else {
+                                                return;
+                                            };
+
+                                            this.collapse_entry(target_entry.clone(), worktree, cx);
+                                        }
+                                    },
+                                ))
+                                .with_render_fn(cx.entity().clone(), move |this, params, _, cx| {
                                     const LEFT_OFFSET: Pixels = px(14.);
                                     const PADDING_Y: Pixels = px(4.);
                                     const HITBOX_OVERDRAW: Pixels = px(3.);
@@ -5260,12 +5259,11 @@ impl Render for ProjectPanel {
                                             }
                                         })
                                         .collect()
-                                },
-                            ),
+                                }),
                         )
                     })
                     .when(show_sticky_scroll, |list| {
-                        list.with_decoration(ui::sticky_items(
+                        let sticky_items = ui::sticky_items(
                             cx.entity().clone(),
                             |this, range, window, cx| {
                                 let mut items = SmallVec::with_capacity(range.end - range.start);
@@ -5286,7 +5284,40 @@ impl Render for ProjectPanel {
                             |this, marker_entry, window, cx| {
                                 this.render_sticky_entries(marker_entry, window, cx)
                             },
-                        ))
+                        );
+                        list.with_decoration(if show_indent_guides {
+                            sticky_items.with_decoration(
+                                ui::indent_guides(px(indent_size), IndentGuideColors::panel(cx))
+                                    .with_render_fn(cx.entity().clone(), move |_, params, _, _| {
+                                        const LEFT_OFFSET: Pixels = px(14.);
+
+                                        let indent_size = params.indent_size;
+                                        let item_height = params.item_height;
+
+                                        params
+                                            .indent_guides
+                                            .into_iter()
+                                            .map(|layout| {
+                                                let bounds = Bounds::new(
+                                                    point(
+                                                        layout.offset.x * indent_size + LEFT_OFFSET,
+                                                        layout.offset.y * item_height,
+                                                    ),
+                                                    size(px(1.), layout.length * item_height),
+                                                );
+                                                ui::RenderedIndentGuide {
+                                                    bounds,
+                                                    layout,
+                                                    is_active: false,
+                                                    hitbox: None,
+                                                }
+                                            })
+                                            .collect()
+                                    }),
+                            )
+                        } else {
+                            sticky_items
+                        })
                     })
                     .size_full()
                     .with_sizing_behavior(ListSizingBehavior::Infer)

crates/storybook/src/stories/indent_guides.rs 🔗

@@ -55,23 +55,27 @@ impl Render for IndentGuidesStory {
                         }),
                     )
                     .with_sizing_behavior(gpui::ListSizingBehavior::Infer)
-                    .with_decoration(ui::indent_guides(
-                        cx.entity().clone(),
-                        px(16.),
-                        ui::IndentGuideColors {
-                            default: Color::Info.color(cx),
-                            hover: Color::Accent.color(cx),
-                            active: Color::Accent.color(cx),
-                        },
-                        |this, range, _cx, _context| {
-                            this.depths
-                                .iter()
-                                .skip(range.start)
-                                .take(range.end - range.start)
-                                .cloned()
-                                .collect()
-                        },
-                    )),
+                    .with_decoration(
+                        ui::indent_guides(
+                            px(16.),
+                            ui::IndentGuideColors {
+                                default: Color::Info.color(cx),
+                                hover: Color::Accent.color(cx),
+                                active: Color::Accent.color(cx),
+                            },
+                        )
+                        .with_compute_indents_fn(
+                            cx.entity().clone(),
+                            |this, range, _cx, _context| {
+                                this.depths
+                                    .iter()
+                                    .skip(range.start)
+                                    .take(range.end - range.start)
+                                    .cloned()
+                                    .collect()
+                            },
+                        ),
+                    ),
                 ),
             )
     }

crates/ui/src/components/indent_guides.rs 🔗

@@ -1,8 +1,7 @@
 use std::{cmp::Ordering, ops::Range, rc::Rc};
 
-use gpui::{
-    AnyElement, App, Bounds, Entity, Hsla, Point, UniformListDecoration, fill, point, size,
-};
+use gpui::{AnyElement, App, Bounds, Entity, Hsla, Point, fill, point, size};
+use gpui::{DispatchPhase, Hitbox, HitboxBehavior, MouseButton, MouseDownEvent, MouseMoveEvent};
 use smallvec::SmallVec;
 
 use crate::prelude::*;
@@ -32,7 +31,8 @@ impl IndentGuideColors {
 pub struct IndentGuides {
     colors: IndentGuideColors,
     indent_size: Pixels,
-    compute_indents_fn: Box<dyn Fn(Range<usize>, &mut Window, &mut App) -> SmallVec<[usize; 64]>>,
+    compute_indents_fn:
+        Option<Box<dyn Fn(Range<usize>, &mut Window, &mut App) -> SmallVec<[usize; 64]>>>,
     render_fn: Option<
         Box<
             dyn Fn(
@@ -45,25 +45,11 @@ pub struct IndentGuides {
     on_click: Option<Rc<dyn Fn(&IndentGuideLayout, &mut Window, &mut App)>>,
 }
 
-pub fn indent_guides<V: Render>(
-    entity: Entity<V>,
-    indent_size: Pixels,
-    colors: IndentGuideColors,
-    compute_indents_fn: impl Fn(
-        &mut V,
-        Range<usize>,
-        &mut Window,
-        &mut Context<V>,
-    ) -> SmallVec<[usize; 64]>
-    + 'static,
-) -> IndentGuides {
-    let compute_indents_fn = Box::new(move |range, window: &mut Window, cx: &mut App| {
-        entity.update(cx, |this, cx| compute_indents_fn(this, range, window, cx))
-    });
+pub fn indent_guides(indent_size: Pixels, colors: IndentGuideColors) -> IndentGuides {
     IndentGuides {
         colors,
         indent_size,
-        compute_indents_fn,
+        compute_indents_fn: None,
         render_fn: None,
         on_click: None,
     }
@@ -79,6 +65,25 @@ impl IndentGuides {
         self
     }
 
+    /// Sets the function that computes indents for uniform list decoration.
+    pub fn with_compute_indents_fn<V: Render>(
+        mut self,
+        entity: Entity<V>,
+        compute_indents_fn: impl Fn(
+            &mut V,
+            Range<usize>,
+            &mut Window,
+            &mut Context<V>,
+        ) -> SmallVec<[usize; 64]>
+        + 'static,
+    ) -> Self {
+        let compute_indents_fn = Box::new(move |range, window: &mut Window, cx: &mut App| {
+            entity.update(cx, |this, cx| compute_indents_fn(this, range, window, cx))
+        });
+        self.compute_indents_fn = Some(compute_indents_fn);
+        self
+    }
+
     /// Sets a custom callback that will be called when the indent guides need to be rendered.
     pub fn with_render_fn<V: Render>(
         mut self,
@@ -97,6 +102,53 @@ impl IndentGuides {
         self.render_fn = Some(Box::new(render_fn));
         self
     }
+
+    fn render_from_layout(
+        &self,
+        indent_guides: SmallVec<[IndentGuideLayout; 12]>,
+        bounds: Bounds<Pixels>,
+        item_height: Pixels,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> AnyElement {
+        let mut indent_guides = if let Some(ref custom_render) = self.render_fn {
+            let params = RenderIndentGuideParams {
+                indent_guides,
+                indent_size: self.indent_size,
+                item_height,
+            };
+            custom_render(params, window, cx)
+        } else {
+            indent_guides
+                .into_iter()
+                .map(|layout| RenderedIndentGuide {
+                    bounds: Bounds::new(
+                        point(
+                            layout.offset.x * self.indent_size,
+                            layout.offset.y * item_height,
+                        ),
+                        size(px(1.), layout.length * item_height),
+                    ),
+                    layout,
+                    is_active: false,
+                    hitbox: None,
+                })
+                .collect()
+        };
+        for guide in &mut indent_guides {
+            guide.bounds.origin += bounds.origin;
+            if let Some(hitbox) = guide.hitbox.as_mut() {
+                hitbox.origin += bounds.origin;
+            }
+        }
+
+        let indent_guides = IndentGuidesElement {
+            indent_guides: Rc::new(indent_guides),
+            colors: self.colors.clone(),
+            on_hovered_indent_guide_click: self.on_click.clone(),
+        };
+        indent_guides.into_any_element()
+    }
 }
 
 /// Parameters for rendering indent guides.
@@ -136,9 +188,7 @@ pub struct IndentGuideLayout {
 
 /// Implements the necessary functionality for rendering indent guides inside a uniform list.
 mod uniform_list {
-    use gpui::{
-        DispatchPhase, Hitbox, HitboxBehavior, MouseButton, MouseDownEvent, MouseMoveEvent,
-    };
+    use gpui::UniformListDecoration;
 
     use super::*;
 
@@ -161,227 +211,212 @@ mod uniform_list {
             if includes_trailing_indent {
                 visible_range.end += 1;
             }
-            let visible_entries = &(self.compute_indents_fn)(visible_range.clone(), window, cx);
+            let Some(ref compute_indents_fn) = self.compute_indents_fn else {
+                panic!("compute_indents_fn is required for UniformListDecoration");
+            };
+            let visible_entries = &compute_indents_fn(visible_range.clone(), window, cx);
             let indent_guides = compute_indent_guides(
                 &visible_entries,
                 visible_range.start,
                 includes_trailing_indent,
             );
-            let mut indent_guides = if let Some(ref custom_render) = self.render_fn {
-                let params = RenderIndentGuideParams {
-                    indent_guides,
-                    indent_size: self.indent_size,
-                    item_height,
-                };
-                custom_render(params, window, cx)
-            } else {
-                indent_guides
-                    .into_iter()
-                    .map(|layout| RenderedIndentGuide {
-                        bounds: Bounds::new(
-                            point(
-                                layout.offset.x * self.indent_size,
-                                layout.offset.y * item_height,
-                            ),
-                            size(px(1.), layout.length * item_height),
-                        ),
-                        layout,
-                        is_active: false,
-                        hitbox: None,
-                    })
-                    .collect()
-            };
-            for guide in &mut indent_guides {
-                guide.bounds.origin += bounds.origin;
-                if let Some(hitbox) = guide.hitbox.as_mut() {
-                    hitbox.origin += bounds.origin;
-                }
-            }
-
-            let indent_guides = IndentGuidesElement {
-                indent_guides: Rc::new(indent_guides),
-                colors: self.colors.clone(),
-                on_hovered_indent_guide_click: self.on_click.clone(),
-            };
-            indent_guides.into_any_element()
+            self.render_from_layout(indent_guides, bounds, item_height, window, cx)
         }
     }
+}
 
-    struct IndentGuidesElement {
-        colors: IndentGuideColors,
-        indent_guides: Rc<SmallVec<[RenderedIndentGuide; 12]>>,
-        on_hovered_indent_guide_click:
-            Option<Rc<dyn Fn(&IndentGuideLayout, &mut Window, &mut App)>>,
-    }
+/// Implements the necessary functionality for rendering indent guides inside a sticky items.
+mod sticky_items {
+    use crate::StickyItemsDecoration;
 
-    enum IndentGuidesElementPrepaintState {
-        Static,
-        Interactive {
-            hitboxes: Rc<SmallVec<[Hitbox; 12]>>,
-            on_hovered_indent_guide_click: Rc<dyn Fn(&IndentGuideLayout, &mut Window, &mut App)>,
-        },
+    use super::*;
+
+    impl StickyItemsDecoration for IndentGuides {
+        fn compute(
+            &self,
+            indents: &SmallVec<[usize; 8]>,
+            bounds: Bounds<Pixels>,
+            _scroll_offset: Point<Pixels>,
+            item_height: Pixels,
+            window: &mut Window,
+            cx: &mut App,
+        ) -> AnyElement {
+            let indent_guides = compute_indent_guides(&indents, 0, false);
+            self.render_from_layout(indent_guides, bounds, item_height, window, cx)
+        }
     }
+}
 
-    impl Element for IndentGuidesElement {
-        type RequestLayoutState = ();
-        type PrepaintState = IndentGuidesElementPrepaintState;
+struct IndentGuidesElement {
+    colors: IndentGuideColors,
+    indent_guides: Rc<SmallVec<[RenderedIndentGuide; 12]>>,
+    on_hovered_indent_guide_click: Option<Rc<dyn Fn(&IndentGuideLayout, &mut Window, &mut App)>>,
+}
 
-        fn id(&self) -> Option<ElementId> {
-            None
-        }
+enum IndentGuidesElementPrepaintState {
+    Static,
+    Interactive {
+        hitboxes: Rc<SmallVec<[Hitbox; 12]>>,
+        on_hovered_indent_guide_click: Rc<dyn Fn(&IndentGuideLayout, &mut Window, &mut App)>,
+    },
+}
 
-        fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
-            None
-        }
+impl Element for IndentGuidesElement {
+    type RequestLayoutState = ();
+    type PrepaintState = IndentGuidesElementPrepaintState;
 
-        fn request_layout(
-            &mut self,
-            _id: Option<&gpui::GlobalElementId>,
-            _inspector_id: Option<&gpui::InspectorElementId>,
-            window: &mut Window,
-            cx: &mut App,
-        ) -> (gpui::LayoutId, Self::RequestLayoutState) {
-            (window.request_layout(gpui::Style::default(), [], cx), ())
-        }
+    fn id(&self) -> Option<ElementId> {
+        None
+    }
 
-        fn prepaint(
-            &mut self,
-            _id: Option<&gpui::GlobalElementId>,
-            _inspector_id: Option<&gpui::InspectorElementId>,
-            _bounds: Bounds<Pixels>,
-            _request_layout: &mut Self::RequestLayoutState,
-            window: &mut Window,
-            _cx: &mut App,
-        ) -> Self::PrepaintState {
-            if let Some(on_hovered_indent_guide_click) = self.on_hovered_indent_guide_click.clone()
-            {
-                let hitboxes = self
-                    .indent_guides
-                    .as_ref()
-                    .iter()
-                    .map(|guide| {
-                        window.insert_hitbox(
-                            guide.hitbox.unwrap_or(guide.bounds),
-                            HitboxBehavior::Normal,
-                        )
-                    })
-                    .collect();
-                Self::PrepaintState::Interactive {
-                    hitboxes: Rc::new(hitboxes),
-                    on_hovered_indent_guide_click,
-                }
-            } else {
-                Self::PrepaintState::Static
+    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
+        None
+    }
+
+    fn request_layout(
+        &mut self,
+        _id: Option<&gpui::GlobalElementId>,
+        _inspector_id: Option<&gpui::InspectorElementId>,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> (gpui::LayoutId, Self::RequestLayoutState) {
+        (window.request_layout(gpui::Style::default(), [], cx), ())
+    }
+
+    fn prepaint(
+        &mut self,
+        _id: Option<&gpui::GlobalElementId>,
+        _inspector_id: Option<&gpui::InspectorElementId>,
+        _bounds: Bounds<Pixels>,
+        _request_layout: &mut Self::RequestLayoutState,
+        window: &mut Window,
+        _cx: &mut App,
+    ) -> Self::PrepaintState {
+        if let Some(on_hovered_indent_guide_click) = self.on_hovered_indent_guide_click.clone() {
+            let hitboxes = self
+                .indent_guides
+                .as_ref()
+                .iter()
+                .map(|guide| {
+                    window
+                        .insert_hitbox(guide.hitbox.unwrap_or(guide.bounds), HitboxBehavior::Normal)
+                })
+                .collect();
+            Self::PrepaintState::Interactive {
+                hitboxes: Rc::new(hitboxes),
+                on_hovered_indent_guide_click,
             }
+        } else {
+            Self::PrepaintState::Static
         }
+    }
 
-        fn paint(
-            &mut self,
-            _id: Option<&gpui::GlobalElementId>,
-            _inspector_id: Option<&gpui::InspectorElementId>,
-            _bounds: Bounds<Pixels>,
-            _request_layout: &mut Self::RequestLayoutState,
-            prepaint: &mut Self::PrepaintState,
-            window: &mut Window,
-            _cx: &mut App,
-        ) {
-            let current_view = window.current_view();
-
-            match prepaint {
-                IndentGuidesElementPrepaintState::Static => {
-                    for indent_guide in self.indent_guides.as_ref() {
-                        let fill_color = if indent_guide.is_active {
-                            self.colors.active
-                        } else {
-                            self.colors.default
-                        };
-
-                        window.paint_quad(fill(indent_guide.bounds, fill_color));
-                    }
+    fn paint(
+        &mut self,
+        _id: Option<&gpui::GlobalElementId>,
+        _inspector_id: Option<&gpui::InspectorElementId>,
+        _bounds: Bounds<Pixels>,
+        _request_layout: &mut Self::RequestLayoutState,
+        prepaint: &mut Self::PrepaintState,
+        window: &mut Window,
+        _cx: &mut App,
+    ) {
+        let current_view = window.current_view();
+
+        match prepaint {
+            IndentGuidesElementPrepaintState::Static => {
+                for indent_guide in self.indent_guides.as_ref() {
+                    let fill_color = if indent_guide.is_active {
+                        self.colors.active
+                    } else {
+                        self.colors.default
+                    };
+
+                    window.paint_quad(fill(indent_guide.bounds, fill_color));
                 }
-                IndentGuidesElementPrepaintState::Interactive {
-                    hitboxes,
-                    on_hovered_indent_guide_click,
-                } => {
-                    window.on_mouse_event({
-                        let hitboxes = hitboxes.clone();
-                        let indent_guides = self.indent_guides.clone();
-                        let on_hovered_indent_guide_click = on_hovered_indent_guide_click.clone();
-                        move |event: &MouseDownEvent, phase, window, cx| {
-                            if phase == DispatchPhase::Bubble && event.button == MouseButton::Left {
-                                let mut active_hitbox_ix = None;
-                                for (i, hitbox) in hitboxes.iter().enumerate() {
-                                    if hitbox.is_hovered(window) {
-                                        active_hitbox_ix = Some(i);
-                                        break;
-                                    }
+            }
+            IndentGuidesElementPrepaintState::Interactive {
+                hitboxes,
+                on_hovered_indent_guide_click,
+            } => {
+                window.on_mouse_event({
+                    let hitboxes = hitboxes.clone();
+                    let indent_guides = self.indent_guides.clone();
+                    let on_hovered_indent_guide_click = on_hovered_indent_guide_click.clone();
+                    move |event: &MouseDownEvent, phase, window, cx| {
+                        if phase == DispatchPhase::Bubble && event.button == MouseButton::Left {
+                            let mut active_hitbox_ix = None;
+                            for (i, hitbox) in hitboxes.iter().enumerate() {
+                                if hitbox.is_hovered(window) {
+                                    active_hitbox_ix = Some(i);
+                                    break;
                                 }
+                            }
 
-                                let Some(active_hitbox_ix) = active_hitbox_ix else {
-                                    return;
-                                };
+                            let Some(active_hitbox_ix) = active_hitbox_ix else {
+                                return;
+                            };
 
-                                let active_indent_guide = &indent_guides[active_hitbox_ix].layout;
-                                on_hovered_indent_guide_click(active_indent_guide, window, cx);
+                            let active_indent_guide = &indent_guides[active_hitbox_ix].layout;
+                            on_hovered_indent_guide_click(active_indent_guide, window, cx);
 
-                                cx.stop_propagation();
-                                window.prevent_default();
-                            }
+                            cx.stop_propagation();
+                            window.prevent_default();
                         }
-                    });
-                    let mut hovered_hitbox_id = None;
-                    for (i, hitbox) in hitboxes.iter().enumerate() {
-                        window.set_cursor_style(gpui::CursorStyle::PointingHand, hitbox);
-                        let indent_guide = &self.indent_guides[i];
-                        let fill_color = if hitbox.is_hovered(window) {
-                            hovered_hitbox_id = Some(hitbox.id);
-                            self.colors.hover
-                        } else if indent_guide.is_active {
-                            self.colors.active
-                        } else {
-                            self.colors.default
-                        };
-
-                        window.paint_quad(fill(indent_guide.bounds, fill_color));
                     }
+                });
+                let mut hovered_hitbox_id = None;
+                for (i, hitbox) in hitboxes.iter().enumerate() {
+                    window.set_cursor_style(gpui::CursorStyle::PointingHand, hitbox);
+                    let indent_guide = &self.indent_guides[i];
+                    let fill_color = if hitbox.is_hovered(window) {
+                        hovered_hitbox_id = Some(hitbox.id);
+                        self.colors.hover
+                    } else if indent_guide.is_active {
+                        self.colors.active
+                    } else {
+                        self.colors.default
+                    };
+
+                    window.paint_quad(fill(indent_guide.bounds, fill_color));
+                }
 
-                    window.on_mouse_event({
-                        let prev_hovered_hitbox_id = hovered_hitbox_id;
-                        let hitboxes = hitboxes.clone();
-                        move |_: &MouseMoveEvent, phase, window, cx| {
-                            let mut hovered_hitbox_id = None;
-                            for hitbox in hitboxes.as_ref() {
-                                if hitbox.is_hovered(window) {
-                                    hovered_hitbox_id = Some(hitbox.id);
-                                    break;
-                                }
+                window.on_mouse_event({
+                    let prev_hovered_hitbox_id = hovered_hitbox_id;
+                    let hitboxes = hitboxes.clone();
+                    move |_: &MouseMoveEvent, phase, window, cx| {
+                        let mut hovered_hitbox_id = None;
+                        for hitbox in hitboxes.as_ref() {
+                            if hitbox.is_hovered(window) {
+                                hovered_hitbox_id = Some(hitbox.id);
+                                break;
                             }
-                            if phase == DispatchPhase::Capture {
-                                // If the hovered hitbox has changed, we need to re-paint the indent guides.
-                                match (prev_hovered_hitbox_id, hovered_hitbox_id) {
-                                    (Some(prev_id), Some(id)) => {
-                                        if prev_id != id {
-                                            cx.notify(current_view)
-                                        }
+                        }
+                        if phase == DispatchPhase::Capture {
+                            // If the hovered hitbox has changed, we need to re-paint the indent guides.
+                            match (prev_hovered_hitbox_id, hovered_hitbox_id) {
+                                (Some(prev_id), Some(id)) => {
+                                    if prev_id != id {
+                                        cx.notify(current_view)
                                     }
-                                    (None, Some(_)) => cx.notify(current_view),
-                                    (Some(_), None) => cx.notify(current_view),
-                                    (None, None) => {}
                                 }
+                                (None, Some(_)) => cx.notify(current_view),
+                                (Some(_), None) => cx.notify(current_view),
+                                (None, None) => {}
                             }
                         }
-                    });
-                }
+                    }
+                });
             }
         }
     }
+}
 
-    impl IntoElement for IndentGuidesElement {
-        type Element = Self;
+impl IntoElement for IndentGuidesElement {
+    type Element = Self;
 
-        fn into_element(self) -> Self::Element {
-            self
-        }
+    fn into_element(self) -> Self::Element {
+        self
     }
 }
 

crates/ui/src/components/sticky_items.rs 🔗

@@ -3,7 +3,7 @@ use std::{ops::Range, rc::Rc};
 use gpui::{
     AnyElement, App, AvailableSpace, Bounds, Context, Element, ElementId, Entity, GlobalElementId,
     InspectorElementId, IntoElement, LayoutId, Pixels, Point, Render, Style, UniformListDecoration,
-    Window, point, size,
+    Window, point, px, size,
 };
 use smallvec::SmallVec;
 
@@ -11,10 +11,10 @@ pub trait StickyCandidate {
     fn depth(&self) -> usize;
 }
 
-#[derive(Clone)]
 pub struct StickyItems<T> {
     compute_fn: Rc<dyn Fn(Range<usize>, &mut Window, &mut App) -> SmallVec<[T; 8]>>,
     render_fn: Rc<dyn Fn(T, &mut Window, &mut App) -> SmallVec<[AnyElement; 8]>>,
+    decorations: Vec<Box<dyn StickyItemsDecoration>>,
 }
 
 pub fn sticky_items<V, T>(
@@ -44,11 +44,26 @@ where
     StickyItems {
         compute_fn,
         render_fn,
+        decorations: Vec::new(),
+    }
+}
+
+impl<T> StickyItems<T>
+where
+    T: StickyCandidate + Clone + 'static,
+{
+    /// Adds a decoration element to the sticky items.
+    pub fn with_decoration(mut self, decoration: impl StickyItemsDecoration + 'static) -> Self {
+        self.decorations.push(Box::new(decoration));
+        self
     }
 }
 
 struct StickyItemsElement {
-    elements: SmallVec<[AnyElement; 8]>,
+    drifting_element: Option<AnyElement>,
+    drifting_decoration: Option<AnyElement>,
+    rest_elements: SmallVec<[AnyElement; 8]>,
+    rest_decorations: SmallVec<[AnyElement; 1]>,
 }
 
 impl IntoElement for StickyItemsElement {
@@ -103,8 +118,16 @@ impl Element for StickyItemsElement {
         window: &mut Window,
         cx: &mut App,
     ) {
-        // reverse so that last item is bottom most among sticky items
-        for item in self.elements.iter_mut().rev() {
+        if let Some(ref mut drifting_element) = self.drifting_element {
+            drifting_element.paint(window, cx);
+        }
+        if let Some(ref mut drifting_decoration) = self.drifting_decoration {
+            drifting_decoration.paint(window, cx);
+        }
+        for item in self.rest_elements.iter_mut().rev() {
+            item.paint(window, cx);
+        }
+        for item in self.rest_decorations.iter_mut() {
             item.paint(window, cx);
         }
     }
@@ -125,11 +148,14 @@ where
         cx: &mut App,
     ) -> AnyElement {
         let entries = (self.compute_fn)(visible_range.clone(), window, cx);
-        let mut elements = SmallVec::new();
 
-        let mut anchor_entry = None;
+        struct StickyAnchor<T> {
+            entry: T,
+            index: usize,
+        }
+
+        let mut sticky_anchor = None;
         let mut last_item_is_drifting = false;
-        let mut anchor_index = None;
 
         let mut iter = entries.iter().enumerate().peekable();
         while let Some((ix, current_entry)) = iter.next() {
@@ -137,7 +163,10 @@ where
             let index_in_range = ix;
 
             if current_depth < index_in_range {
-                anchor_entry = Some(current_entry.clone());
+                sticky_anchor = Some(StickyAnchor {
+                    entry: current_entry.clone(),
+                    index: visible_range.start + ix,
+                });
                 break;
             }
 
@@ -146,44 +175,155 @@ where
 
                 if next_depth < current_depth && next_depth < index_in_range {
                     last_item_is_drifting = true;
-                    anchor_index = Some(visible_range.start + ix);
-                    anchor_entry = Some(current_entry.clone());
+                    sticky_anchor = Some(StickyAnchor {
+                        entry: current_entry.clone(),
+                        index: visible_range.start + ix,
+                    });
                     break;
                 }
             }
         }
 
-        if let Some(anchor_entry) = anchor_entry {
-            elements = (self.render_fn)(anchor_entry, window, cx);
-            let items_count = elements.len();
-
-            for (ix, element) in elements.iter_mut().enumerate() {
-                let mut item_y_offset = None;
-                if ix == items_count - 1 && last_item_is_drifting {
-                    if let Some(anchor_index) = anchor_index {
-                        let scroll_top = -scroll_offset.y;
-                        let anchor_top = item_height * anchor_index;
-                        let sticky_area_height = item_height * items_count;
-                        item_y_offset =
-                            Some((anchor_top - scroll_top - sticky_area_height).min(Pixels::ZERO));
-                    };
-                }
+        let Some(sticky_anchor) = sticky_anchor else {
+            return StickyItemsElement {
+                drifting_element: None,
+                drifting_decoration: None,
+                rest_elements: SmallVec::new(),
+                rest_decorations: SmallVec::new(),
+            }
+            .into_any_element();
+        };
+
+        let anchor_depth = sticky_anchor.entry.depth();
+        let mut elements = (self.render_fn)(sticky_anchor.entry, window, cx);
+        let items_count = elements.len();
+
+        let indents: SmallVec<[usize; 8]> = {
+            elements
+                .iter()
+                .enumerate()
+                .map(|(ix, _)| anchor_depth.saturating_sub(items_count.saturating_sub(ix)))
+                .collect()
+        };
+
+        let mut last_decoration_element = None;
+        let mut rest_decoration_elements = SmallVec::new();
+
+        let available_space = size(
+            AvailableSpace::Definite(bounds.size.width),
+            AvailableSpace::Definite(bounds.size.height),
+        );
+
+        let drifting_y_offset = if last_item_is_drifting {
+            let scroll_top = -scroll_offset.y;
+            let anchor_top = item_height * sticky_anchor.index;
+            let sticky_area_height = item_height * items_count;
+            (anchor_top - scroll_top - sticky_area_height).min(Pixels::ZERO)
+        } else {
+            Pixels::ZERO
+        };
+
+        let (drifting_indent, rest_indents) = if last_item_is_drifting && !indents.is_empty() {
+            let last = indents[indents.len() - 1];
+            let rest: SmallVec<[usize; 8]> = indents[..indents.len() - 1].iter().copied().collect();
+            (Some(last), rest)
+        } else {
+            (None, indents)
+        };
 
-                let sticky_origin = bounds.origin
-                    + point(
-                        -scroll_offset.x,
-                        -scroll_offset.y + item_height * ix + item_y_offset.unwrap_or(Pixels::ZERO),
-                    );
+        for decoration in &self.decorations {
+            if let Some(drifting_indent) = drifting_indent {
+                let drifting_indent_vec: SmallVec<[usize; 8]> =
+                    [drifting_indent].into_iter().collect();
+                let sticky_origin = bounds.origin - scroll_offset
+                    + point(px(0.), item_height * rest_indents.len() + drifting_y_offset);
+                let decoration_bounds = Bounds::new(sticky_origin, bounds.size);
 
-                let available_space = size(
-                    AvailableSpace::Definite(bounds.size.width),
-                    AvailableSpace::Definite(item_height),
+                let mut drifting_dec = decoration.as_ref().compute(
+                    &drifting_indent_vec,
+                    decoration_bounds,
+                    scroll_offset,
+                    item_height,
+                    window,
+                    cx,
                 );
-                element.layout_as_root(available_space, window, cx);
-                element.prepaint_at(sticky_origin, window, cx);
+                drifting_dec.layout_as_root(available_space, window, cx);
+                drifting_dec.prepaint_at(sticky_origin, window, cx);
+                last_decoration_element = Some(drifting_dec);
             }
+
+            if !rest_indents.is_empty() {
+                let decoration_bounds = Bounds::new(bounds.origin - scroll_offset, bounds.size);
+                let mut rest_dec = decoration.as_ref().compute(
+                    &rest_indents,
+                    decoration_bounds,
+                    scroll_offset,
+                    item_height,
+                    window,
+                    cx,
+                );
+                rest_dec.layout_as_root(available_space, window, cx);
+                rest_dec.prepaint_at(bounds.origin, window, cx);
+                rest_decoration_elements.push(rest_dec);
+            }
+        }
+
+        let (mut drifting_element, mut rest_elements) =
+            if last_item_is_drifting && !elements.is_empty() {
+                let last = elements.pop().unwrap();
+                (Some(last), elements)
+            } else {
+                (None, elements)
+            };
+
+        for (ix, element) in rest_elements.iter_mut().enumerate() {
+            let sticky_origin = bounds.origin - scroll_offset + point(px(0.), item_height * ix);
+            let element_available_space = size(
+                AvailableSpace::Definite(bounds.size.width),
+                AvailableSpace::Definite(item_height),
+            );
+
+            element.layout_as_root(element_available_space, window, cx);
+            element.prepaint_at(sticky_origin, window, cx);
+        }
+
+        if let Some(ref mut drifting_element) = drifting_element {
+            let sticky_origin = bounds.origin - scroll_offset
+                + point(
+                    px(0.),
+                    item_height * rest_elements.len() + drifting_y_offset,
+                );
+            let element_available_space = size(
+                AvailableSpace::Definite(bounds.size.width),
+                AvailableSpace::Definite(item_height),
+            );
+
+            drifting_element.layout_as_root(element_available_space, window, cx);
+            drifting_element.prepaint_at(sticky_origin, window, cx);
         }
 
-        StickyItemsElement { elements }.into_any_element()
+        StickyItemsElement {
+            drifting_element,
+            drifting_decoration: last_decoration_element,
+            rest_elements,
+            rest_decorations: rest_decoration_elements,
+        }
+        .into_any_element()
     }
 }
+
+/// A decoration for a [`StickyItems`]. This can be used for various things,
+/// such as rendering indent guides, or other visual effects.
+pub trait StickyItemsDecoration {
+    /// Compute the decoration element, given the visible range of list items,
+    /// the bounds of the list, and the height of each item.
+    fn compute(
+        &self,
+        indents: &SmallVec<[usize; 8]>,
+        bounds: Bounds<Pixels>,
+        scroll_offset: Point<Pixels>,
+        item_height: Pixels,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> AnyElement;
+}