gpui: Add `scroll_to_item_with_offset` to `UniformListScrollState` (#35064)

Smit Barmase created

Previously we had `ScrollStrategy::ToPosition(usize)` which lets you
define the offset where you want to scroll that item to. This is the
same as `ScrollStrategy::Top` but imagine some space reserved at the
top.

This PR removes `ScrollStrategy::ToPosition` in favor of
`scroll_to_item_with_offset` which is the method to do the same. The
reason to add this method is that now not just `ScrollStrategy::Top` but
`ScrollStrategy::Center` can also uses this offset to center the item in
the remaining unreserved space.

```rs
// Before
scroll_handle.scroll_to_item(index, ScrollStrategy::ToPosition(offset));

// After
scroll_handle.scroll_to_item_with_offset(index, ScrollStrategy::Top, offset);

// New! Centers item skipping first x items
scroll_handle.scroll_to_item_with_offset(index, ScrollStrategy::Center, offset);
```

This will be useful for follow up PR.

Release Notes:

- N/A

Change summary

crates/gpui/src/elements/uniform_list.rs  | 64 +++++++++++++++++-------
crates/project_panel/src/project_panel.rs |  5 -
2 files changed, 45 insertions(+), 24 deletions(-)

Detailed changes

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

@@ -88,15 +88,24 @@ pub enum ScrollStrategy {
     /// May not be possible if there's not enough list items above the item scrolled to:
     /// in this case, the element will be placed at the closest possible position.
     Center,
-    /// Scrolls the element to be at the given item index from the top of the viewport.
-    ToPosition(usize),
+}
+
+#[derive(Clone, Copy, Debug)]
+#[allow(missing_docs)]
+pub struct DeferredScrollToItem {
+    /// The item index to scroll to
+    pub item_index: usize,
+    /// The scroll strategy to use
+    pub strategy: ScrollStrategy,
+    /// The offset in number of items
+    pub offset: usize,
 }
 
 #[derive(Clone, Debug, Default)]
 #[allow(missing_docs)]
 pub struct UniformListScrollState {
     pub base_handle: ScrollHandle,
-    pub deferred_scroll_to_item: Option<(usize, ScrollStrategy)>,
+    pub deferred_scroll_to_item: Option<DeferredScrollToItem>,
     /// Size of the item, captured during last layout.
     pub last_item_size: Option<ItemSize>,
     /// Whether the list was vertically flipped during last layout.
@@ -126,7 +135,24 @@ impl UniformListScrollHandle {
 
     /// Scroll the list to the given item index.
     pub fn scroll_to_item(&self, ix: usize, strategy: ScrollStrategy) {
-        self.0.borrow_mut().deferred_scroll_to_item = Some((ix, strategy));
+        self.0.borrow_mut().deferred_scroll_to_item = Some(DeferredScrollToItem {
+            item_index: ix,
+            strategy,
+            offset: 0,
+        });
+    }
+
+    /// Scroll the list to the given item index with an offset.
+    ///
+    /// For ScrollStrategy::Top, the item will be placed at the offset position from the top.
+    ///
+    /// For ScrollStrategy::Center, the item will be centered between offset and the last visible item.
+    pub fn scroll_to_item_with_offset(&self, ix: usize, strategy: ScrollStrategy, offset: usize) {
+        self.0.borrow_mut().deferred_scroll_to_item = Some(DeferredScrollToItem {
+            item_index: ix,
+            strategy,
+            offset,
+        });
     }
 
     /// Check if the list is flipped vertically.
@@ -139,7 +165,8 @@ impl UniformListScrollHandle {
     pub fn logical_scroll_top_index(&self) -> usize {
         let this = self.0.borrow();
         this.deferred_scroll_to_item
-            .map(|(ix, _)| ix)
+            .as_ref()
+            .map(|deferred| deferred.item_index)
             .unwrap_or_else(|| this.base_handle.logical_scroll_top().0)
     }
 
@@ -320,7 +347,8 @@ impl Element for UniformList {
                         scroll_offset.x = Pixels::ZERO;
                     }
 
-                    if let Some((mut ix, scroll_strategy)) = shared_scroll_to_item {
+                    if let Some(deferred_scroll) = shared_scroll_to_item {
+                        let mut ix = deferred_scroll.item_index;
                         if y_flipped {
                             ix = self.item_count.saturating_sub(ix + 1);
                         }
@@ -329,23 +357,28 @@ impl Element for UniformList {
                         let item_top = item_height * ix + padding.top;
                         let item_bottom = item_top + item_height;
                         let scroll_top = -updated_scroll_offset.y;
+                        let offset_pixels = item_height * deferred_scroll.offset;
                         let mut scrolled_to_top = false;
-                        if item_top < scroll_top + padding.top {
+
+                        if item_top < scroll_top + padding.top + offset_pixels {
                             scrolled_to_top = true;
-                            updated_scroll_offset.y = -(item_top) + padding.top;
+                            updated_scroll_offset.y = -(item_top) + padding.top + offset_pixels;
                         } else if item_bottom > scroll_top + list_height - padding.bottom {
                             scrolled_to_top = true;
                             updated_scroll_offset.y = -(item_bottom - list_height) - padding.bottom;
                         }
 
-                        match scroll_strategy {
+                        match deferred_scroll.strategy {
                             ScrollStrategy::Top => {}
                             ScrollStrategy::Center => {
                                 if scrolled_to_top {
                                     let item_center = item_top + item_height / 2.0;
-                                    let target_scroll_top = item_center - list_height / 2.0;
 
-                                    if item_top < scroll_top
+                                    let viewport_height = list_height - offset_pixels;
+                                    let viewport_center = offset_pixels + viewport_height / 2.0;
+                                    let target_scroll_top = item_center - viewport_center;
+
+                                    if item_top < scroll_top + offset_pixels
                                         || item_bottom > scroll_top + list_height
                                     {
                                         updated_scroll_offset.y = -target_scroll_top
@@ -355,15 +388,6 @@ impl Element for UniformList {
                                     }
                                 }
                             }
-                            ScrollStrategy::ToPosition(sticky_index) => {
-                                let target_y_in_viewport = item_height * sticky_index;
-                                let target_scroll_top = item_top - target_y_in_viewport;
-                                let max_scroll_top =
-                                    (content_height - list_height).max(Pixels::ZERO);
-                                let new_scroll_top =
-                                    target_scroll_top.clamp(Pixels::ZERO, max_scroll_top);
-                                updated_scroll_offset.y = -new_scroll_top;
-                            }
                         }
                         scroll_offset = *updated_scroll_offset
                     }

crates/project_panel/src/project_panel.rs 🔗

@@ -4207,10 +4207,7 @@ impl ProjectPanel {
                         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);
+                                this.scroll_handle.scroll_to_item_with_offset(index, ScrollStrategy::Top, sticky_index.unwrap_or(0));
                                 cx.notify();
                                 // move down by 1px so that clicked item
                                 // don't count as sticky anymore