recent projects: cleanup ui (#7528)

Bennet Bo Fenner created

As the ui for the file finder was recently changed in #7364, I think it
makes sense to also update the ui of the recent projects overlay.

Before:

![image](https://github.com/zed-industries/zed/assets/53836821/8a0f5bef-9b37-40f3-a974-9dfd7833cc71)

After:

![image](https://github.com/zed-industries/zed/assets/53836821/7e9f934a-1ac3-4716-b7b6-67a7435f3bde)


Release Notes:

- Improved UI of recent project overlay

Change summary

crates/collab_ui/src/collab_panel/channel_modal.rs           |   2 
crates/collab_ui/src/collab_panel/contact_finder.rs          |   2 
crates/command_palette/src/command_palette.rs                |   2 
crates/file_finder/src/file_finder.rs                        |   2 
crates/gpui/src/elements/list.rs                             | 347 ++++-
crates/gpui/src/elements/uniform_list.rs                     |   2 
crates/language_selector/src/language_selector.rs            |   2 
crates/outline/src/outline.rs                                |   2 
crates/picker/src/picker.rs                                  | 162 +
crates/project_symbols/src/project_symbols.rs                |   4 
crates/recent_projects/src/highlighted_workspace_location.rs |   3 
crates/recent_projects/src/recent_projects.rs                |  54 
crates/storybook/src/stories/picker.rs                       |   2 
crates/theme_selector/src/theme_selector.rs                  |   2 
crates/vcs_menu/src/lib.rs                                   |   2 
crates/welcome/src/base_keymap_picker.rs                     |   2 
16 files changed, 405 insertions(+), 187 deletions(-)

Detailed changes

crates/collab_ui/src/collab_panel/channel_modal.rs 🔗

@@ -43,7 +43,7 @@ impl ChannelModal {
         cx.observe(&channel_store, |_, _, cx| cx.notify()).detach();
         let channel_modal = cx.view().downgrade();
         let picker = cx.new_view(|cx| {
-            Picker::new(
+            Picker::uniform_list(
                 ChannelModalDelegate {
                     channel_modal,
                     matching_users: Vec::new(),

crates/collab_ui/src/collab_panel/contact_finder.rs 🔗

@@ -22,7 +22,7 @@ impl ContactFinder {
             potential_contacts: Arc::from([]),
             selected_index: 0,
         };
-        let picker = cx.new_view(|cx| Picker::new(delegate, cx).modal(false));
+        let picker = cx.new_view(|cx| Picker::uniform_list(delegate, cx).modal(false));
 
         Self { picker }
     }

crates/command_palette/src/command_palette.rs 🔗

@@ -80,7 +80,7 @@ impl CommandPalette {
             previous_focus_handle,
         );
 
-        let picker = cx.new_view(|cx| Picker::new(delegate, cx));
+        let picker = cx.new_view(|cx| Picker::uniform_list(delegate, cx));
         Self { picker }
     }
 }

crates/file_finder/src/file_finder.rs 🔗

@@ -93,7 +93,7 @@ impl FileFinder {
 
     fn new(delegate: FileFinderDelegate, cx: &mut ViewContext<Self>) -> Self {
         Self {
-            picker: cx.new_view(|cx| Picker::new(delegate, cx)),
+            picker: cx.new_view(|cx| Picker::uniform_list(delegate, cx)),
         }
     }
 }

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

@@ -7,20 +7,22 @@
 //! If all of your elements are the same height, see [`UniformList`] for a simpler API
 
 use crate::{
-    point, px, AnyElement, AvailableSpace, Bounds, ContentMask, DispatchPhase, Element,
-    IntoElement, Pixels, Point, ScrollWheelEvent, Size, Style, StyleRefinement, Styled,
-    WindowContext,
+    point, px, size, AnyElement, AvailableSpace, Bounds, ContentMask, DispatchPhase, Edges,
+    Element, ElementContext, IntoElement, Pixels, Point, ScrollWheelEvent, Size, Style,
+    StyleRefinement, Styled, WindowContext,
 };
 use collections::VecDeque;
 use refineable::Refineable as _;
 use std::{cell::RefCell, ops::Range, rc::Rc};
 use sum_tree::{Bias, SumTree};
+use taffy::style::Overflow;
 
 /// Construct a new list element
 pub fn list(state: ListState) -> List {
     List {
         state,
         style: StyleRefinement::default(),
+        sizing_behavior: ListSizingBehavior::default(),
     }
 }
 
@@ -28,6 +30,15 @@ pub fn list(state: ListState) -> List {
 pub struct List {
     state: ListState,
     style: StyleRefinement,
+    sizing_behavior: ListSizingBehavior,
+}
+
+impl List {
+    /// Set the sizing behavior for the list.
+    pub fn with_sizing_behavior(mut self, behavior: ListSizingBehavior) -> Self {
+        self.sizing_behavior = behavior;
+        self
+    }
 }
 
 /// The list state that views must hold on behalf of the list element.
@@ -36,6 +47,7 @@ pub struct ListState(Rc<RefCell<StateInner>>);
 
 struct StateInner {
     last_layout_bounds: Option<Bounds<Pixels>>,
+    last_padding: Option<Edges<Pixels>>,
     render_item: Box<dyn FnMut(usize, &mut WindowContext) -> AnyElement>,
     items: SumTree<ListItem>,
     logical_scroll_top: Option<ListOffset>,
@@ -67,10 +79,27 @@ pub struct ListScrollEvent {
     pub is_scrolled: bool,
 }
 
+/// The sizing behavior to apply during layout.
+#[derive(Clone, Copy, Debug, Default, PartialEq)]
+pub enum ListSizingBehavior {
+    /// The list should calculate its size based on the size of its items.
+    Infer,
+    /// The list should not calculate a fixed size.
+    #[default]
+    Auto,
+}
+
+struct LayoutItemsResponse {
+    max_item_width: Pixels,
+    scroll_top: ListOffset,
+    available_item_space: Size<AvailableSpace>,
+    item_elements: VecDeque<AnyElement>,
+}
+
 #[derive(Clone)]
 enum ListItem {
     Unrendered,
-    Rendered { height: Pixels },
+    Rendered { size: Size<Pixels> },
 }
 
 #[derive(Clone, Debug, Default, PartialEq)]
@@ -112,6 +141,7 @@ impl ListState {
         items.extend((0..element_count).map(|_| ListItem::Unrendered), &());
         Self(Rc::new(RefCell::new(StateInner {
             last_layout_bounds: None,
+            last_padding: None,
             render_item: Box::new(render_item),
             items,
             logical_scroll_top: None,
@@ -202,6 +232,7 @@ impl ListState {
         let height = state
             .last_layout_bounds
             .map_or(px(0.), |bounds| bounds.size.height);
+        let padding = state.last_padding.unwrap_or_default();
 
         if ix <= scroll_top.item_ix {
             scroll_top.item_ix = ix;
@@ -209,7 +240,7 @@ impl ListState {
         } else {
             let mut cursor = state.items.cursor::<ListItemSummary>();
             cursor.seek(&Count(ix + 1), Bias::Right, &());
-            let bottom = cursor.start().height;
+            let bottom = cursor.start().height + padding.top;
             let goal_top = px(0.).max(bottom - height);
 
             cursor.seek(&Height(goal_top), Bias::Left, &());
@@ -242,13 +273,13 @@ impl ListState {
         let scroll_top = cursor.start().1 .0 + scroll_top.offset_in_item;
 
         cursor.seek_forward(&Count(ix), Bias::Right, &());
-        if let Some(&ListItem::Rendered { height }) = cursor.item() {
+        if let Some(&ListItem::Rendered { size }) = cursor.item() {
             let &(Count(count), Height(top)) = cursor.start();
             if count == ix {
                 let top = bounds.top() + top - scroll_top;
                 return Some(Bounds::from_corners(
                     point(bounds.left(), top),
-                    point(bounds.right(), top + height),
+                    point(bounds.right(), top + size.height),
                 ));
             }
         }
@@ -271,6 +302,7 @@ impl StateInner {
         height: Pixels,
         delta: Point<Pixels>,
         cx: &mut WindowContext,
+        padding: Edges<Pixels>,
     ) {
         // Drop scroll events after a reset, since we can't calculate
         // the new logical scroll top without the item heights
@@ -278,7 +310,8 @@ impl StateInner {
             return;
         }
 
-        let scroll_max = (self.items.summary().height - height).max(px(0.));
+        let scroll_max =
+            (self.items.summary().height + padding.top + padding.bottom - height).max(px(0.));
         let new_scroll_top = (self.scroll_top(scroll_top) - delta.y)
             .max(px(0.))
             .min(scroll_max);
@@ -330,75 +363,27 @@ impl StateInner {
         cursor.seek(&Count(logical_scroll_top.item_ix), Bias::Right, &());
         cursor.start().height + logical_scroll_top.offset_in_item
     }
-}
-
-impl std::fmt::Debug for ListItem {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        match self {
-            Self::Unrendered => write!(f, "Unrendered"),
-            Self::Rendered { height, .. } => {
-                f.debug_struct("Rendered").field("height", height).finish()
-            }
-        }
-    }
-}
-
-/// An offset into the list's items, in terms of the item index and the number
-/// of pixels off the top left of the item.
-#[derive(Debug, Clone, Copy, Default)]
-pub struct ListOffset {
-    /// The index of an item in the list
-    pub item_ix: usize,
-    /// The number of pixels to offset from the item index.
-    pub offset_in_item: Pixels,
-}
-
-impl Element for List {
-    type State = ();
-
-    fn request_layout(
-        &mut self,
-        _state: Option<Self::State>,
-        cx: &mut crate::ElementContext,
-    ) -> (crate::LayoutId, Self::State) {
-        let mut style = Style::default();
-        style.refine(&self.style);
-        let layout_id = cx.with_text_style(style.text_style().cloned(), |cx| {
-            cx.request_layout(&style, None)
-        });
-        (layout_id, ())
-    }
 
-    fn paint(
+    fn layout_items(
         &mut self,
-        bounds: Bounds<crate::Pixels>,
-        _state: &mut Self::State,
-        cx: &mut crate::ElementContext,
-    ) {
-        let state = &mut *self.state.0.borrow_mut();
-
-        state.reset = false;
-
-        // If the width of the list has changed, invalidate all cached item heights
-        if state.last_layout_bounds.map_or(true, |last_bounds| {
-            last_bounds.size.width != bounds.size.width
-        }) {
-            state.items = SumTree::from_iter(
-                (0..state.items.summary().count).map(|_| ListItem::Unrendered),
-                &(),
-            )
-        }
-
-        let old_items = state.items.clone();
+        available_width: Option<Pixels>,
+        available_height: Pixels,
+        padding: &Edges<Pixels>,
+        cx: &mut ElementContext,
+    ) -> LayoutItemsResponse {
+        let old_items = self.items.clone();
         let mut measured_items = VecDeque::new();
         let mut item_elements = VecDeque::new();
-        let mut rendered_height = px(0.);
-        let mut scroll_top = state.logical_scroll_top();
-
-        let available_item_space = Size {
-            width: AvailableSpace::Definite(bounds.size.width),
-            height: AvailableSpace::MinContent,
-        };
+        let mut rendered_height = padding.top;
+        let mut max_item_width = px(0.);
+        let mut scroll_top = self.logical_scroll_top();
+
+        let available_item_space = size(
+            available_width.map_or(AvailableSpace::MinContent, |width| {
+                AvailableSpace::Definite(width)
+            }),
+            AvailableSpace::MinContent,
+        );
 
         let mut cursor = old_items.cursor::<Count>();
 
@@ -406,48 +391,48 @@ impl Element for List {
         cursor.seek(&Count(scroll_top.item_ix), Bias::Right, &());
         for (ix, item) in cursor.by_ref().enumerate() {
             let visible_height = rendered_height - scroll_top.offset_in_item;
-            if visible_height >= bounds.size.height + state.overdraw {
+            if visible_height >= available_height + self.overdraw {
                 break;
             }
 
             // Use the previously cached height if available
-            let mut height = if let ListItem::Rendered { height } = item {
-                Some(*height)
+            let mut size = if let ListItem::Rendered { size } = item {
+                Some(*size)
             } else {
                 None
             };
 
             // If we're within the visible area or the height wasn't cached, render and measure the item's element
-            if visible_height < bounds.size.height || height.is_none() {
-                let mut element = (state.render_item)(scroll_top.item_ix + ix, cx);
+            if visible_height < available_height || size.is_none() {
+                let mut element = (self.render_item)(scroll_top.item_ix + ix, cx);
                 let element_size = element.measure(available_item_space, cx);
-                height = Some(element_size.height);
-                if visible_height < bounds.size.height {
+                size = Some(element_size);
+                if visible_height < available_height {
                     item_elements.push_back(element);
                 }
             }
 
-            let height = height.unwrap();
-            rendered_height += height;
-            measured_items.push_back(ListItem::Rendered { height });
+            let size = size.unwrap();
+            rendered_height += size.height;
+            max_item_width = max_item_width.max(size.width);
+            measured_items.push_back(ListItem::Rendered { size });
         }
+        rendered_height += padding.bottom;
 
         // Prepare to start walking upward from the item at the scroll top.
         cursor.seek(&Count(scroll_top.item_ix), Bias::Right, &());
 
         // If the rendered items do not fill the visible region, then adjust
         // the scroll top upward.
-        if rendered_height - scroll_top.offset_in_item < bounds.size.height {
-            while rendered_height < bounds.size.height {
+        if rendered_height - scroll_top.offset_in_item < available_height {
+            while rendered_height < available_height {
                 cursor.prev(&());
                 if cursor.item().is_some() {
-                    let mut element = (state.render_item)(cursor.start().0, cx);
+                    let mut element = (self.render_item)(cursor.start().0, cx);
                     let element_size = element.measure(available_item_space, cx);
 
                     rendered_height += element_size.height;
-                    measured_items.push_front(ListItem::Rendered {
-                        height: element_size.height,
-                    });
+                    measured_items.push_front(ListItem::Rendered { size: element_size });
                     item_elements.push_front(element)
                 } else {
                     break;
@@ -456,38 +441,38 @@ impl Element for List {
 
             scroll_top = ListOffset {
                 item_ix: cursor.start().0,
-                offset_in_item: rendered_height - bounds.size.height,
+                offset_in_item: rendered_height - available_height,
             };
 
-            match state.alignment {
+            match self.alignment {
                 ListAlignment::Top => {
                     scroll_top.offset_in_item = scroll_top.offset_in_item.max(px(0.));
-                    state.logical_scroll_top = Some(scroll_top);
+                    self.logical_scroll_top = Some(scroll_top);
                 }
                 ListAlignment::Bottom => {
                     scroll_top = ListOffset {
                         item_ix: cursor.start().0,
-                        offset_in_item: rendered_height - bounds.size.height,
+                        offset_in_item: rendered_height - available_height,
                     };
-                    state.logical_scroll_top = None;
+                    self.logical_scroll_top = None;
                 }
             };
         }
 
         // Measure items in the leading overdraw
         let mut leading_overdraw = scroll_top.offset_in_item;
-        while leading_overdraw < state.overdraw {
+        while leading_overdraw < self.overdraw {
             cursor.prev(&());
             if let Some(item) = cursor.item() {
-                let height = if let ListItem::Rendered { height } = item {
-                    *height
+                let size = if let ListItem::Rendered { size } = item {
+                    *size
                 } else {
-                    let mut element = (state.render_item)(cursor.start().0, cx);
-                    element.measure(available_item_space, cx).height
+                    let mut element = (self.render_item)(cursor.start().0, cx);
+                    element.measure(available_item_space, cx)
                 };
 
-                leading_overdraw += height;
-                measured_items.push_front(ListItem::Rendered { height });
+                leading_overdraw += size.height;
+                measured_items.push_front(ListItem::Rendered { size });
             } else {
                 break;
             }
@@ -500,19 +485,152 @@ impl Element for List {
         cursor.seek(&Count(measured_range.end), Bias::Right, &());
         new_items.append(cursor.suffix(&()), &());
 
-        // Paint the visible items
-        cx.with_content_mask(Some(ContentMask { bounds }), |cx| {
-            let mut item_origin = bounds.origin;
-            item_origin.y -= scroll_top.offset_in_item;
-            for item_element in &mut item_elements {
-                let item_height = item_element.measure(available_item_space, cx).height;
-                item_element.draw(item_origin, available_item_space, cx);
-                item_origin.y += item_height;
+        self.items = new_items;
+
+        LayoutItemsResponse {
+            max_item_width,
+            scroll_top,
+            available_item_space,
+            item_elements,
+        }
+    }
+}
+
+impl std::fmt::Debug for ListItem {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            Self::Unrendered => write!(f, "Unrendered"),
+            Self::Rendered { size, .. } => f.debug_struct("Rendered").field("size", size).finish(),
+        }
+    }
+}
+
+/// An offset into the list's items, in terms of the item index and the number
+/// of pixels off the top left of the item.
+#[derive(Debug, Clone, Copy, Default)]
+pub struct ListOffset {
+    /// The index of an item in the list
+    pub item_ix: usize,
+    /// The number of pixels to offset from the item index.
+    pub offset_in_item: Pixels,
+}
+
+impl Element for List {
+    type State = ();
+
+    fn request_layout(
+        &mut self,
+        _state: Option<Self::State>,
+        cx: &mut crate::ElementContext,
+    ) -> (crate::LayoutId, Self::State) {
+        let layout_id = match self.sizing_behavior {
+            ListSizingBehavior::Infer => {
+                let mut style = Style::default();
+                style.overflow.y = Overflow::Scroll;
+                style.refine(&self.style);
+                cx.with_text_style(style.text_style().cloned(), |cx| {
+                    let state = &mut *self.state.0.borrow_mut();
+
+                    let available_height = if let Some(last_bounds) = state.last_layout_bounds {
+                        last_bounds.size.height
+                    } else {
+                        // If we don't have the last layout bounds (first render),
+                        // we might just use the overdraw value as the available height to layout enough items.
+                        state.overdraw
+                    };
+                    let padding = style.padding.to_pixels(
+                        state.last_layout_bounds.unwrap_or_default().size.into(),
+                        cx.rem_size(),
+                    );
+
+                    let layout_response = state.layout_items(None, available_height, &padding, cx);
+                    let max_element_width = layout_response.max_item_width;
+
+                    let summary = state.items.summary();
+                    let total_height = summary.height;
+                    let all_rendered = summary.unrendered_count == 0;
+
+                    if all_rendered {
+                        cx.request_measured_layout(
+                            style,
+                            move |known_dimensions, available_space, _cx| {
+                                let width = known_dimensions.width.unwrap_or(match available_space
+                                    .width
+                                {
+                                    AvailableSpace::Definite(x) => x,
+                                    AvailableSpace::MinContent | AvailableSpace::MaxContent => {
+                                        max_element_width
+                                    }
+                                });
+                                let height = match available_space.height {
+                                    AvailableSpace::Definite(height) => total_height.min(height),
+                                    AvailableSpace::MinContent | AvailableSpace::MaxContent => {
+                                        total_height
+                                    }
+                                };
+                                size(width, height)
+                            },
+                        )
+                    } else {
+                        cx.request_layout(&style, None)
+                    }
+                })
             }
-        });
+            ListSizingBehavior::Auto => {
+                let mut style = Style::default();
+                style.refine(&self.style);
+                cx.with_text_style(style.text_style().cloned(), |cx| {
+                    cx.request_layout(&style, None)
+                })
+            }
+        };
+        (layout_id, ())
+    }
+
+    fn paint(
+        &mut self,
+        bounds: Bounds<crate::Pixels>,
+        _state: &mut Self::State,
+        cx: &mut crate::ElementContext,
+    ) {
+        let state = &mut *self.state.0.borrow_mut();
+        state.reset = false;
+
+        let mut style = Style::default();
+        style.refine(&self.style);
+
+        // If the width of the list has changed, invalidate all cached item heights
+        if state.last_layout_bounds.map_or(true, |last_bounds| {
+            last_bounds.size.width != bounds.size.width
+        }) {
+            state.items = SumTree::from_iter(
+                (0..state.items.summary().count).map(|_| ListItem::Unrendered),
+                &(),
+            )
+        }
+
+        let padding = style.padding.to_pixels(bounds.size.into(), cx.rem_size());
+        let mut layout_response =
+            state.layout_items(Some(bounds.size.width), bounds.size.height, &padding, cx);
+
+        // Only paint the visible items, if there is actually any space for them (taking padding into account)
+        if bounds.size.height > padding.top + padding.bottom {
+            // Paint the visible items
+            cx.with_content_mask(Some(ContentMask { bounds }), |cx| {
+                let mut item_origin = bounds.origin + Point::new(px(0.), padding.top);
+                item_origin.y -= layout_response.scroll_top.offset_in_item;
+                for item_element in &mut layout_response.item_elements {
+                    let item_height = item_element
+                        .measure(layout_response.available_item_space, cx)
+                        .height;
+                    item_element.draw(item_origin, layout_response.available_item_space, cx);
+                    item_origin.y += item_height;
+                }
+            });
+        }
 
-        state.items = new_items;
         state.last_layout_bounds = Some(bounds);
+        state.last_padding = Some(padding);
 
         let list_state = self.state.clone();
         let height = bounds.size.height;
@@ -523,10 +641,11 @@ impl Element for List {
                 && cx.was_top_layer(&event.position, cx.stacking_order())
             {
                 list_state.0.borrow_mut().scroll(
-                    &scroll_top,
+                    &layout_response.scroll_top,
                     height,
                     event.delta.pixel_delta(px(20.)),
                     cx,
+                    padding,
                 )
             }
         });
@@ -562,11 +681,11 @@ impl sum_tree::Item for ListItem {
                 unrendered_count: 1,
                 height: px(0.),
             },
-            ListItem::Rendered { height } => ListItemSummary {
+            ListItem::Rendered { size } => ListItemSummary {
                 count: 1,
                 rendered_count: 1,
                 unrendered_count: 0,
-                height: *height,
+                height: size.height,
             },
         }
     }

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

@@ -218,7 +218,7 @@ impl Element for UniformList {
                     if let Some(ix) = shared_scroll_to_item {
                         let list_height = padded_bounds.size.height;
                         let mut updated_scroll_offset = shared_scroll_offset.borrow_mut();
-                        let item_top = item_height * ix;
+                        let item_top = item_height * ix + padding.top;
                         let item_bottom = item_top + item_height;
                         let scroll_top = -updated_scroll_offset.y;
                         if item_top < scroll_top {

crates/language_selector/src/language_selector.rs 🔗

@@ -61,7 +61,7 @@ impl LanguageSelector {
             language_registry,
         );
 
-        let picker = cx.new_view(|cx| Picker::new(delegate, cx));
+        let picker = cx.new_view(|cx| Picker::uniform_list(delegate, cx));
         Self { picker }
     }
 }

crates/outline/src/outline.rs 🔗

@@ -86,7 +86,7 @@ impl OutlineView {
         cx: &mut ViewContext<Self>,
     ) -> OutlineView {
         let delegate = OutlineViewDelegate::new(cx.view().downgrade(), outline, editor, cx);
-        let picker = cx.new_view(|cx| Picker::new(delegate, cx).max_height(vh(0.75, cx)));
+        let picker = cx.new_view(|cx| Picker::uniform_list(delegate, cx).max_height(vh(0.75, cx)));
         OutlineView { picker }
     }
 }

crates/picker/src/picker.rs 🔗

@@ -1,16 +1,21 @@
 use editor::Editor;
 use gpui::{
-    div, prelude::*, uniform_list, AnyElement, AppContext, DismissEvent, EventEmitter, FocusHandle,
-    FocusableView, Length, MouseButton, MouseDownEvent, Render, Task, UniformListScrollHandle,
-    View, ViewContext, WindowContext,
+    div, list, prelude::*, uniform_list, AnyElement, AppContext, DismissEvent, EventEmitter,
+    FocusHandle, FocusableView, Length, ListState, MouseButton, MouseDownEvent, Render, Task,
+    UniformListScrollHandle, View, ViewContext, WindowContext,
 };
 use std::sync::Arc;
 use ui::{prelude::*, v_flex, Color, Divider, Label, ListItem, ListItemSpacing};
 use workspace::ModalView;
 
+enum ElementContainer {
+    List(ListState),
+    UniformList(UniformListScrollHandle),
+}
+
 pub struct Picker<D: PickerDelegate> {
     pub delegate: D,
-    scroll_handle: UniformListScrollHandle,
+    element_container: ElementContainer,
     editor: View<Editor>,
     pending_update_matches: Option<Task<()>>,
     confirm_on_update: Option<bool>,
@@ -65,14 +70,27 @@ fn create_editor(placeholder: Arc<str>, cx: &mut WindowContext<'_>) -> View<Edit
         editor
     })
 }
+
 impl<D: PickerDelegate> Picker<D> {
-    pub fn new(delegate: D, cx: &mut ViewContext<Self>) -> Self {
+    /// A picker, which displays its matches using `gpui::uniform_list`, all matches should have the same height.
+    /// If `PickerDelegate::render_match` can return items with different heights, use `Picker::list`.
+    pub fn uniform_list(delegate: D, cx: &mut ViewContext<Self>) -> Self {
+        Self::new(delegate, cx, true)
+    }
+
+    /// A picker, which displays its matches using `gpui::list`, matches can have different heights.
+    /// If `PickerDelegate::render_match` only returns items with the same height, use `Picker::uniform_list` as its implementation is optimized for that.
+    pub fn list(delegate: D, cx: &mut ViewContext<Self>) -> Self {
+        Self::new(delegate, cx, false)
+    }
+
+    fn new(delegate: D, cx: &mut ViewContext<Self>, is_uniform: bool) -> Self {
         let editor = create_editor(delegate.placeholder_text(), cx);
         cx.subscribe(&editor, Self::on_input_editor_event).detach();
         let mut this = Self {
             delegate,
             editor,
-            scroll_handle: UniformListScrollHandle::new(),
+            element_container: Self::crate_element_container(is_uniform, cx),
             pending_update_matches: None,
             confirm_on_update: None,
             width: None,
@@ -83,6 +101,28 @@ impl<D: PickerDelegate> Picker<D> {
         this
     }
 
+    fn crate_element_container(is_uniform: bool, cx: &mut ViewContext<Self>) -> ElementContainer {
+        if is_uniform {
+            ElementContainer::UniformList(UniformListScrollHandle::new())
+        } else {
+            let view = cx.view().downgrade();
+            ElementContainer::List(ListState::new(
+                0,
+                gpui::ListAlignment::Top,
+                px(1000.),
+                move |ix, cx| {
+                    view.upgrade()
+                        .map(|view| {
+                            view.update(cx, |this, cx| {
+                                this.render_element(cx, ix).into_any_element()
+                            })
+                        })
+                        .unwrap_or_else(|| div().into_any_element())
+                },
+            ))
+        }
+    }
+
     pub fn width(mut self, width: impl Into<gpui::Length>) -> Self {
         self.width = Some(width.into());
         self
@@ -108,7 +148,7 @@ impl<D: PickerDelegate> Picker<D> {
             let index = self.delegate.selected_index();
             let ix = if index == count - 1 { 0 } else { index + 1 };
             self.delegate.set_selected_index(ix, cx);
-            self.scroll_handle.scroll_to_item(ix);
+            self.scroll_to_item_index(ix);
             cx.notify();
         }
     }
@@ -119,7 +159,7 @@ impl<D: PickerDelegate> Picker<D> {
             let index = self.delegate.selected_index();
             let ix = if index == 0 { count - 1 } else { index - 1 };
             self.delegate.set_selected_index(ix, cx);
-            self.scroll_handle.scroll_to_item(ix);
+            self.scroll_to_item_index(ix);
             cx.notify();
         }
     }
@@ -128,7 +168,7 @@ impl<D: PickerDelegate> Picker<D> {
         let count = self.delegate.match_count();
         if count > 0 {
             self.delegate.set_selected_index(0, cx);
-            self.scroll_handle.scroll_to_item(0);
+            self.scroll_to_item_index(0);
             cx.notify();
         }
     }
@@ -137,7 +177,7 @@ impl<D: PickerDelegate> Picker<D> {
         let count = self.delegate.match_count();
         if count > 0 {
             self.delegate.set_selected_index(count - 1, cx);
-            self.scroll_handle.scroll_to_item(count - 1);
+            self.scroll_to_item_index(count - 1);
             cx.notify();
         }
     }
@@ -147,7 +187,7 @@ impl<D: PickerDelegate> Picker<D> {
         let index = self.delegate.selected_index();
         let new_index = if index + 1 == count { 0 } else { index + 1 };
         self.delegate.set_selected_index(new_index, cx);
-        self.scroll_handle.scroll_to_item(new_index);
+        self.scroll_to_item_index(new_index);
         cx.notify();
     }
 
@@ -215,8 +255,12 @@ impl<D: PickerDelegate> Picker<D> {
     }
 
     fn matches_updated(&mut self, cx: &mut ViewContext<Self>) {
+        if let ElementContainer::List(state) = &mut self.element_container {
+            state.reset(self.delegate.match_count());
+        }
+
         let index = self.delegate.selected_index();
-        self.scroll_handle.scroll_to_item(index);
+        self.scroll_to_item_index(index);
         self.pending_update_matches = None;
         if let Some(secondary) = self.confirm_on_update.take() {
             self.delegate.confirm(secondary, cx);
@@ -232,6 +276,58 @@ impl<D: PickerDelegate> Picker<D> {
         self.editor
             .update(cx, |editor, cx| editor.set_text(query, cx));
     }
+
+    fn scroll_to_item_index(&mut self, ix: usize) {
+        match &mut self.element_container {
+            ElementContainer::List(state) => state.scroll_to_reveal_item(ix),
+            ElementContainer::UniformList(scroll_handle) => scroll_handle.scroll_to_item(ix),
+        }
+    }
+
+    fn render_element(&self, cx: &mut ViewContext<Self>, ix: usize) -> impl IntoElement {
+        div()
+            .on_mouse_down(
+                MouseButton::Left,
+                cx.listener(move |this, event: &MouseDownEvent, cx| {
+                    this.handle_click(ix, event.modifiers.command, cx)
+                }),
+            )
+            .children(
+                self.delegate
+                    .render_match(ix, ix == self.delegate.selected_index(), cx),
+            )
+            .when(
+                self.delegate.separators_after_indices().contains(&ix),
+                |picker| {
+                    picker
+                        .border_color(cx.theme().colors().border_variant)
+                        .border_b_1()
+                        .pb(px(-1.0))
+                },
+            )
+    }
+
+    fn render_element_container(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
+        match &self.element_container {
+            ElementContainer::UniformList(scroll_handle) => uniform_list(
+                cx.view().clone(),
+                "candidates",
+                self.delegate.match_count(),
+                move |picker, visible_range, cx| {
+                    visible_range
+                        .map(|ix| picker.render_element(cx, ix))
+                        .collect()
+                },
+            )
+            .py_2()
+            .track_scroll(scroll_handle.clone())
+            .into_any_element(),
+            ElementContainer::List(state) => list(state.clone())
+                .with_sizing_behavior(gpui::ListSizingBehavior::Infer)
+                .py_2()
+                .into_any_element(),
+        }
+    }
 }
 
 impl<D: PickerDelegate> EventEmitter<DismissEvent> for Picker<D> {}
@@ -269,50 +365,10 @@ impl<D: PickerDelegate> Render for Picker<D> {
                 el.child(
                     v_flex()
                         .flex_grow()
-                        .py_2()
                         .max_h(self.max_height.unwrap_or(rems(18.).into()))
                         .overflow_hidden()
                         .children(self.delegate.render_header(cx))
-                        .child(
-                            uniform_list(
-                                cx.view().clone(),
-                                "candidates",
-                                self.delegate.match_count(),
-                                {
-                                    let separators_after_indices = self.delegate.separators_after_indices();
-                                    let selected_index = self.delegate.selected_index();
-                                    move |picker, visible_range, cx| {
-                                        visible_range
-                                            .map(|ix| {
-                                                div()
-                                                    .on_mouse_down(
-                                                        MouseButton::Left,
-                                                        cx.listener(move |this, event: &MouseDownEvent, cx| {
-                                                            this.handle_click(
-                                                                ix,
-                                                                event.modifiers.command,
-                                                                cx,
-                                                            )
-                                                        }),
-                                                    )
-                                                    .children(picker.delegate.render_match(
-                                                        ix,
-                                                        ix == selected_index,
-                                                        cx,
-                                                    )).when(separators_after_indices.contains(&ix), |picker| {
-                                                        picker
-                                                            .border_color(cx.theme().colors().border_variant)
-                                                            .border_b_1()
-                                                            .pb(px(-1.0))
-                                                    })
-                                            })
-                                            .collect()
-                                    }
-                                },
-                            )
-                            .track_scroll(self.scroll_handle.clone())
-                        )
-
+                        .child(self.render_element_container(cx)),
                 )
             })
             .when(self.delegate.match_count() == 0, |el| {

crates/project_symbols/src/project_symbols.rs 🔗

@@ -25,7 +25,7 @@ pub fn init(cx: &mut AppContext) {
                 let handle = cx.view().downgrade();
                 workspace.toggle_modal(cx, move |cx| {
                     let delegate = ProjectSymbolsDelegate::new(handle, project);
-                    Picker::new(delegate, cx).width(rems(34.))
+                    Picker::uniform_list(delegate, cx).width(rems(34.))
                 })
             });
         },
@@ -344,7 +344,7 @@ mod tests {
 
         // Create the project symbols view.
         let symbols = cx.new_view(|cx| {
-            Picker::new(
+            Picker::uniform_list(
                 ProjectSymbolsDelegate::new(workspace.downgrade(), project.clone()),
                 cx,
             )

crates/recent_projects/src/highlighted_workspace_location.rs 🔗

@@ -5,7 +5,7 @@ use ui::{prelude::*, HighlightedLabel};
 use util::paths::PathExt;
 use workspace::WorkspaceLocation;
 
-#[derive(IntoElement)]
+#[derive(Clone, IntoElement)]
 pub struct HighlightedText {
     pub text: String,
     pub highlight_positions: Vec<usize>,
@@ -48,6 +48,7 @@ impl RenderOnce for HighlightedText {
     }
 }
 
+#[derive(Clone)]
 pub struct HighlightedWorkspaceLocation {
     pub names: HighlightedText,
     pub paths: Vec<HighlightedText>,

crates/recent_projects/src/recent_projects.rs 🔗

@@ -10,7 +10,7 @@ use highlighted_workspace_location::HighlightedWorkspaceLocation;
 use ordered_float::OrderedFloat;
 use picker::{Picker, PickerDelegate};
 use std::sync::Arc;
-use ui::{prelude::*, ListItem, ListItemSpacing};
+use ui::{prelude::*, tooltip_container, HighlightedLabel, ListItem, ListItemSpacing};
 use util::paths::PathExt;
 use workspace::{ModalView, Workspace, WorkspaceLocation, WORKSPACE_DB};
 
@@ -30,7 +30,14 @@ impl ModalView for RecentProjects {}
 
 impl RecentProjects {
     fn new(delegate: RecentProjectsDelegate, rem_width: f32, cx: &mut ViewContext<Self>) -> Self {
-        let picker = cx.new_view(|cx| Picker::new(delegate, cx));
+        let picker = cx.new_view(|cx| {
+            // We want to use a list when we render paths, because the items can have different heights (multiple paths).
+            if delegate.render_paths {
+                Picker::list(delegate, cx)
+            } else {
+                Picker::uniform_list(delegate, cx)
+            }
+        });
         let _subscription = cx.subscribe(&picker, |_, _, _, cx| cx.emit(DismissEvent));
         // We do not want to block the UI on a potentially lengthy call to DB, so we're gonna swap
         // out workspace locations once the future runs to completion.
@@ -82,7 +89,7 @@ impl RecentProjects {
                 workspace.toggle_modal(cx, |cx| {
                     let delegate = RecentProjectsDelegate::new(weak_workspace, true);
 
-                    let modal = RecentProjects::new(delegate, 34., cx);
+                    let modal = Self::new(delegate, 34., cx);
                     modal
                 });
             })?;
@@ -139,7 +146,7 @@ impl PickerDelegate for RecentProjectsDelegate {
     type ListItem = ListItem;
 
     fn placeholder_text(&self) -> Arc<str> {
-        "Recent Projects...".into()
+        "Search recent projects...".into()
     }
 
     fn match_count(&self) -> usize {
@@ -230,6 +237,8 @@ impl PickerDelegate for RecentProjectsDelegate {
             &self.workspace_locations[r#match.candidate_id],
         );
 
+        let tooltip_highlighted_location = highlighted_location.clone();
+
         Some(
             ListItem::new(ix)
                 .inset(true)
@@ -239,9 +248,42 @@ impl PickerDelegate for RecentProjectsDelegate {
                     v_flex()
                         .child(highlighted_location.names)
                         .when(self.render_paths, |this| {
-                            this.children(highlighted_location.paths)
+                            this.children(highlighted_location.paths.into_iter().map(|path| {
+                                HighlightedLabel::new(path.text, path.highlight_positions)
+                                    .size(LabelSize::Small)
+                                    .color(Color::Muted)
+                            }))
                         }),
-                ),
+                )
+                .tooltip(move |cx| {
+                    let tooltip_highlighted_location = tooltip_highlighted_location.clone();
+                    cx.new_view(move |_| MatchTooltip {
+                        highlighted_location: tooltip_highlighted_location,
+                    })
+                    .into()
+                }),
         )
     }
 }
+
+struct MatchTooltip {
+    highlighted_location: HighlightedWorkspaceLocation,
+}
+
+impl Render for MatchTooltip {
+    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
+        tooltip_container(cx, |div, _| {
+            div.children(
+                self.highlighted_location
+                    .paths
+                    .clone()
+                    .into_iter()
+                    .map(|path| {
+                        HighlightedLabel::new(path.text, path.highlight_positions)
+                            .size(LabelSize::Small)
+                            .color(Color::Muted)
+                    }),
+            )
+        })
+    }
+}

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

@@ -190,7 +190,7 @@ impl PickerStory {
                     ]);
                     delegate.update_matches("".into(), cx).detach();
 
-                    let picker = Picker::new(delegate, cx);
+                    let picker = Picker::uniform_list(delegate, cx);
                     picker.focus(cx);
                     picker
                 }),

crates/theme_selector/src/theme_selector.rs 🔗

@@ -60,7 +60,7 @@ impl Render for ThemeSelector {
 
 impl ThemeSelector {
     pub fn new(delegate: ThemeSelectorDelegate, cx: &mut ViewContext<Self>) -> Self {
-        let picker = cx.new_view(|cx| Picker::new(delegate, cx));
+        let picker = cx.new_view(|cx| Picker::uniform_list(delegate, cx));
         Self { picker }
     }
 }

crates/vcs_menu/src/lib.rs 🔗

@@ -34,7 +34,7 @@ pub struct BranchList {
 
 impl BranchList {
     fn new(delegate: BranchListDelegate, rem_width: f32, cx: &mut ViewContext<Self>) -> Self {
-        let picker = cx.new_view(|cx| Picker::new(delegate, cx));
+        let picker = cx.new_view(|cx| Picker::uniform_list(delegate, cx));
         let _subscription = cx.subscribe(&picker, |_, _, _, cx| cx.emit(DismissEvent));
         Self {
             picker,

crates/welcome/src/base_keymap_picker.rs 🔗

@@ -55,7 +55,7 @@ impl BaseKeymapSelector {
         delegate: BaseKeymapSelectorDelegate,
         cx: &mut ViewContext<BaseKeymapSelector>,
     ) -> Self {
-        let picker = cx.new_view(|cx| Picker::new(delegate, cx));
+        let picker = cx.new_view(|cx| Picker::uniform_list(delegate, cx));
         Self { picker }
     }
 }