project panel: Add vertical scrollbar (#13358)

Piotr Osiewicz created

Fixes #4865
Release Notes:

- Added vertical scrollbar to project panel

Change summary

assets/settings/default.json                       |   9 
crates/gpui/src/elements/div.rs                    |   5 
crates/gpui/src/elements/uniform_list.rs           |  43 ++-
crates/project_panel/src/project_panel.rs          | 153 +++++++++++++--
crates/project_panel/src/project_panel_settings.rs |  32 +++
crates/project_panel/src/scrollbar.rs              | 152 +++++++++++++++
6 files changed, 357 insertions(+), 37 deletions(-)

Detailed changes

assets/settings/default.json 🔗

@@ -312,7 +312,14 @@
     "auto_reveal_entries": true,
     /// Whether to fold directories automatically
     /// when a directory has only one directory inside.
-    "auto_fold_dirs": false
+    "auto_fold_dirs": false,
+    /// Scrollbar-related settings
+    "scrollbar": {
+      /// When to show the scrollbar in the project panel.
+      ///
+      /// Default: always
+      "show": "always"
+    }
   },
   "outline_panel": {
     // Whether to show the outline panel button in the status bar

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

@@ -2493,6 +2493,11 @@ impl ScrollHandle {
         self.0.borrow().bounds
     }
 
+    /// Set the bounds into which this child is painted
+    pub(super) fn set_bounds(&self, bounds: Bounds<Pixels>) {
+        self.0.borrow_mut().bounds = bounds;
+    }
+
     /// Get the bounds for a specific child.
     pub fn bounds_for_item(&self, ix: usize) -> Option<Bounds<Pixels>> {
         self.0.borrow().child_bounds.get(ix).cloned()

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

@@ -79,31 +79,37 @@ pub struct UniformListFrameState {
 
 /// A handle for controlling the scroll position of a uniform list.
 /// This should be stored in your view and passed to the uniform_list on each frame.
-#[derive(Clone, Default)]
-pub struct UniformListScrollHandle {
-    base_handle: ScrollHandle,
-    deferred_scroll_to_item: Rc<RefCell<Option<usize>>>,
+#[derive(Clone, Debug, Default)]
+pub struct UniformListScrollHandle(pub Rc<RefCell<UniformListScrollState>>);
+
+#[derive(Clone, Debug, Default)]
+#[allow(missing_docs)]
+pub struct UniformListScrollState {
+    pub base_handle: ScrollHandle,
+    pub deferred_scroll_to_item: Option<usize>,
+    pub last_item_height: Option<Pixels>,
 }
 
 impl UniformListScrollHandle {
     /// Create a new scroll handle to bind to a uniform list.
     pub fn new() -> Self {
-        Self {
+        Self(Rc::new(RefCell::new(UniformListScrollState {
             base_handle: ScrollHandle::new(),
-            deferred_scroll_to_item: Rc::new(RefCell::new(None)),
-        }
+            deferred_scroll_to_item: None,
+            last_item_height: None,
+        })))
     }
 
     /// Scroll the list to the given item index.
     pub fn scroll_to_item(&mut self, ix: usize) {
-        self.deferred_scroll_to_item.replace(Some(ix));
+        self.0.borrow_mut().deferred_scroll_to_item = Some(ix);
     }
 
     /// Get the index of the topmost visible child.
     pub fn logical_scroll_top_index(&self) -> usize {
-        self.deferred_scroll_to_item
-            .borrow()
-            .unwrap_or_else(|| self.base_handle.logical_scroll_top().0)
+        let this = self.0.borrow();
+        this.deferred_scroll_to_item
+            .unwrap_or_else(|| this.base_handle.logical_scroll_top().0)
     }
 }
 
@@ -195,10 +201,11 @@ impl Element for UniformList {
         let shared_scroll_offset = self.interactivity.scroll_offset.clone().unwrap();
 
         let item_height = self.measure_item(Some(padded_bounds.size.width), cx).height;
-        let shared_scroll_to_item = self
-            .scroll_handle
-            .as_mut()
-            .and_then(|handle| handle.deferred_scroll_to_item.take());
+        let shared_scroll_to_item = self.scroll_handle.as_mut().and_then(|handle| {
+            let mut handle = handle.0.borrow_mut();
+            handle.last_item_height = Some(item_height);
+            handle.deferred_scroll_to_item.take()
+        });
 
         self.interactivity.prepaint(
             global_id,
@@ -214,6 +221,10 @@ impl Element for UniformList {
                     bounds.lower_right() - point(border.right + padding.right, border.bottom),
                 );
 
+                if let Some(handle) = self.scroll_handle.as_mut() {
+                    handle.0.borrow_mut().base_handle.set_bounds(bounds);
+                }
+
                 if self.item_count > 0 {
                     let content_height =
                         item_height * self.item_count + padding.top + padding.bottom;
@@ -326,7 +337,7 @@ impl UniformList {
 
     /// Track and render scroll state of this list with reference to the given scroll handle.
     pub fn track_scroll(mut self, handle: UniformListScrollHandle) -> Self {
-        self.interactivity.tracked_scroll_handle = Some(handle.base_handle.clone());
+        self.interactivity.tracked_scroll_handle = Some(handle.0.borrow().base_handle.clone());
         self.scroll_handle = Some(handle);
         self
     }

crates/project_panel/src/project_panel.rs 🔗

@@ -1,9 +1,15 @@
 mod project_panel_settings;
+mod scrollbar;
 use client::{ErrorCode, ErrorExt};
+use scrollbar::ProjectPanelScrollbar;
 use settings::{Settings, SettingsStore};
 
 use db::kvp::KEY_VALUE_STORE;
-use editor::{items::entry_git_aware_label_color, scroll::Autoscroll, Editor};
+use editor::{
+    items::entry_git_aware_label_color,
+    scroll::{Autoscroll, ScrollbarAutoHide},
+    Editor,
+};
 use file_icons::FileIcons;
 
 use anyhow::{anyhow, Result};
@@ -19,7 +25,7 @@ use gpui::{
 };
 use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrev};
 use project::{Entry, EntryKind, Fs, Project, ProjectEntryId, ProjectPath, Worktree, WorktreeId};
-use project_panel_settings::{ProjectPanelDockPosition, ProjectPanelSettings};
+use project_panel_settings::{ProjectPanelDockPosition, ProjectPanelSettings, ShowScrollbar};
 use serde::{Deserialize, Serialize};
 use std::{
     cell::OnceCell,
@@ -28,6 +34,7 @@ use std::{
     ops::Range,
     path::{Path, PathBuf},
     sync::Arc,
+    time::Duration,
 };
 use theme::ThemeSettings;
 use ui::{prelude::*, v_flex, ContextMenu, Icon, KeyBinding, Label, ListItem, Tooltip};
@@ -63,6 +70,8 @@ pub struct ProjectPanel {
     workspace: WeakView<Workspace>,
     width: Option<Pixels>,
     pending_serialization: Task<Option<()>>,
+    show_scrollbar: bool,
+    hide_scrollbar_task: Option<Task<()>>,
 }
 
 #[derive(Clone, Debug)]
@@ -188,7 +197,10 @@ impl ProjectPanel {
         let project_panel = cx.new_view(|cx: &mut ViewContext<Self>| {
             let focus_handle = cx.focus_handle();
             cx.on_focus(&focus_handle, Self::focus_in).detach();
-
+            cx.on_focus_out(&focus_handle, |this, _, cx| {
+                this.hide_scrollbar(cx);
+            })
+            .detach();
             cx.subscribe(&project, |this, project, event, cx| match event {
                 project::Event::ActiveEntryChanged(Some(entry_id)) => {
                     if ProjectPanelSettings::get_global(cx).auto_reveal_entries {
@@ -273,6 +285,8 @@ impl ProjectPanel {
                 workspace: workspace.weak_handle(),
                 width: None,
                 pending_serialization: Task::ready(None),
+                show_scrollbar: !Self::should_autohide_scrollbar(cx),
+                hide_scrollbar_task: None,
             };
             this.update_visible_entries(None, cx);
 
@@ -2201,6 +2215,73 @@ impl ProjectPanel {
             )
     }
 
+    fn render_scrollbar(
+        &self,
+        items_count: usize,
+        cx: &mut ViewContext<Self>,
+    ) -> Option<Stateful<Div>> {
+        let settings = ProjectPanelSettings::get_global(cx);
+        if settings.scrollbar.show == ShowScrollbar::Never {
+            return None;
+        }
+        let scroll_handle = self.scroll_handle.0.borrow();
+
+        let height = scroll_handle
+            .last_item_height
+            .filter(|_| self.show_scrollbar)?;
+
+        let total_list_length = height.0 as f64 * items_count as f64;
+        let current_offset = scroll_handle.base_handle.offset().y.0.min(0.).abs() as f64;
+        let mut percentage = current_offset / total_list_length;
+        let mut end_offset = (current_offset
+            + scroll_handle.base_handle.bounds().size.height.0 as f64)
+            / total_list_length;
+        // Uniform scroll handle might briefly report an offset greater than the length of a list;
+        // in such case we'll adjust the starting offset as well to keep the scrollbar thumb length stable.
+        let overshoot = (end_offset - 1.).clamp(0., 1.);
+        if overshoot > 0. {
+            percentage -= overshoot;
+        }
+        if percentage + 0.005 > 1.0 || end_offset > total_list_length {
+            return None;
+        }
+        if total_list_length < scroll_handle.base_handle.bounds().size.height.0 as f64 {
+            percentage = 0.;
+            end_offset = 1.;
+        }
+        let end_offset = end_offset.clamp(percentage + 0.005, 1.);
+        Some(
+            div()
+                .occlude()
+                .id("project-panel-scroll")
+                .on_mouse_move(cx.listener(|_, _, cx| {
+                    cx.notify();
+                    cx.stop_propagation()
+                }))
+                .on_hover(|_, cx| {
+                    cx.stop_propagation();
+                })
+                .on_any_mouse_down(|_, cx| {
+                    cx.stop_propagation();
+                })
+                .on_scroll_wheel(cx.listener(|_, _, cx| {
+                    cx.notify();
+                }))
+                .h_full()
+                .absolute()
+                .right_0()
+                .top_0()
+                .bottom_0()
+                .w_3()
+                .cursor_default()
+                .child(ProjectPanelScrollbar::new(
+                    percentage as f32..end_offset as f32,
+                    self.scroll_handle.clone(),
+                    items_count,
+                )),
+        )
+    }
+
     fn dispatch_context(&self, cx: &ViewContext<Self>) -> KeyContext {
         let mut dispatch_context = KeyContext::new_with_defaults();
         dispatch_context.add("ProjectPanel");
@@ -2216,6 +2297,29 @@ impl ProjectPanel {
         dispatch_context
     }
 
+    fn should_autohide_scrollbar(cx: &AppContext) -> bool {
+        cx.try_global::<ScrollbarAutoHide>()
+            .map_or_else(|| cx.should_auto_hide_scrollbars(), |autohide| autohide.0)
+    }
+
+    fn hide_scrollbar(&mut self, cx: &mut ViewContext<Self>) {
+        const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
+        if !Self::should_autohide_scrollbar(cx) {
+            return;
+        }
+        self.hide_scrollbar_task = Some(cx.spawn(|panel, mut cx| async move {
+            cx.background_executor()
+                .timer(SCROLLBAR_SHOW_INTERVAL)
+                .await;
+            panel
+                .update(&mut cx, |editor, cx| {
+                    editor.show_scrollbar = false;
+                    cx.notify();
+                })
+                .log_err();
+        }))
+    }
+
     fn reveal_entry(
         &mut self,
         project: Model<Project>,
@@ -2249,10 +2353,26 @@ impl Render for ProjectPanel {
         let project = self.project.read(cx);
 
         if has_worktree {
+            let items_count = self
+                .visible_entries
+                .iter()
+                .map(|(_, worktree_entries, _)| worktree_entries.len())
+                .sum();
+
             h_flex()
                 .id("project-panel")
+                .group("project-panel")
                 .size_full()
                 .relative()
+                .on_hover(cx.listener(|this, hovered, cx| {
+                    if *hovered {
+                        this.show_scrollbar = true;
+                        this.hide_scrollbar_task.take();
+                        cx.notify();
+                    } else if !this.focus_handle.contains_focused(cx) {
+                        this.hide_scrollbar(cx);
+                    }
+                }))
                 .key_context(self.dispatch_context(cx))
                 .on_action(cx.listener(Self::select_next))
                 .on_action(cx.listener(Self::select_prev))
@@ -2298,27 +2418,20 @@ impl Render for ProjectPanel {
                 )
                 .track_focus(&self.focus_handle)
                 .child(
-                    uniform_list(
-                        cx.view().clone(),
-                        "entries",
-                        self.visible_entries
-                            .iter()
-                            .map(|(_, worktree_entries, _)| worktree_entries.len())
-                            .sum(),
-                        {
-                            |this, range, cx| {
-                                let mut items = Vec::new();
-                                this.for_each_visible_entry(range, cx, |id, details, cx| {
-                                    items.push(this.render_entry(id, details, cx));
-                                });
-                                items
-                            }
-                        },
-                    )
+                    uniform_list(cx.view().clone(), "entries", items_count, {
+                        |this, range, cx| {
+                            let mut items = Vec::new();
+                            this.for_each_visible_entry(range, cx, |id, details, cx| {
+                                items.push(this.render_entry(id, details, cx));
+                            });
+                            items
+                        }
+                    })
                     .size_full()
                     .with_sizing_behavior(ListSizingBehavior::Infer)
                     .track_scroll(self.scroll_handle.clone()),
                 )
+                .children(self.render_scrollbar(items_count, cx))
                 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
                     deferred(
                         anchored()

crates/project_panel/src/project_panel_settings.rs 🔗

@@ -22,6 +22,36 @@ pub struct ProjectPanelSettings {
     pub indent_size: f32,
     pub auto_reveal_entries: bool,
     pub auto_fold_dirs: bool,
+    pub scrollbar: ScrollbarSettings,
+}
+
+/// When to show the scrollbar in the project panel.
+///
+/// Default: always
+#[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
+#[serde(rename_all = "snake_case")]
+pub enum ShowScrollbar {
+    #[default]
+    /// Always show the scrollbar.
+    Always,
+    /// Never show the scrollbar.
+    Never,
+}
+
+#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
+pub struct ScrollbarSettings {
+    /// When to show the scrollbar in the project panel.
+    ///
+    /// Default: always
+    pub show: ShowScrollbar,
+}
+
+#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
+pub struct ScrollbarSettingsContent {
+    /// When to show the scrollbar in the project panel.
+    ///
+    /// Default: always
+    pub show: Option<ShowScrollbar>,
 }
 
 #[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)]
@@ -65,6 +95,8 @@ pub struct ProjectPanelSettingsContent {
     ///
     /// Default: false
     pub auto_fold_dirs: Option<bool>,
+    /// Scrollbar-related settings
+    pub scrollbar: Option<ScrollbarSettingsContent>,
 }
 
 impl Settings for ProjectPanelSettings {

crates/project_panel/src/scrollbar.rs 🔗

@@ -0,0 +1,152 @@
+use std::ops::Range;
+
+use gpui::{
+    point, Bounds, ContentMask, Hitbox, MouseDownEvent, MouseMoveEvent, ScrollWheelEvent, Style,
+    UniformListScrollHandle,
+};
+use ui::{prelude::*, px, relative, IntoElement};
+
+pub(crate) struct ProjectPanelScrollbar {
+    thumb: Range<f32>,
+    scroll: UniformListScrollHandle,
+    item_count: usize,
+}
+
+impl ProjectPanelScrollbar {
+    pub(crate) fn new(
+        thumb: Range<f32>,
+        scroll: UniformListScrollHandle,
+        item_count: usize,
+    ) -> Self {
+        Self {
+            thumb,
+            scroll,
+            item_count,
+        }
+    }
+}
+
+impl gpui::Element for ProjectPanelScrollbar {
+    type RequestLayoutState = ();
+
+    type PrepaintState = Hitbox;
+
+    fn id(&self) -> Option<ui::ElementId> {
+        None
+    }
+
+    fn request_layout(
+        &mut self,
+        _id: Option<&gpui::GlobalElementId>,
+        cx: &mut ui::WindowContext,
+    ) -> (gpui::LayoutId, Self::RequestLayoutState) {
+        let mut style = Style::default();
+        style.flex_grow = 1.;
+        style.flex_shrink = 1.;
+        style.size.width = px(12.).into();
+        style.size.height = relative(1.).into();
+        (cx.request_layout(style, None), ())
+    }
+
+    fn prepaint(
+        &mut self,
+        _id: Option<&gpui::GlobalElementId>,
+        bounds: Bounds<ui::Pixels>,
+        _request_layout: &mut Self::RequestLayoutState,
+        cx: &mut ui::WindowContext,
+    ) -> Self::PrepaintState {
+        cx.with_content_mask(Some(ContentMask { bounds }), |cx| {
+            cx.insert_hitbox(bounds, false)
+        })
+    }
+
+    fn paint(
+        &mut self,
+        _id: Option<&gpui::GlobalElementId>,
+        bounds: Bounds<ui::Pixels>,
+        _request_layout: &mut Self::RequestLayoutState,
+        _prepaint: &mut Self::PrepaintState,
+        cx: &mut ui::WindowContext,
+    ) {
+        let hitbox_id = _prepaint.id;
+        cx.with_content_mask(Some(ContentMask { bounds }), |cx| {
+            let colors = cx.theme().colors();
+            let scrollbar_background = colors.scrollbar_track_border;
+            let thumb_background = colors.scrollbar_thumb_background;
+            cx.paint_quad(gpui::fill(bounds, scrollbar_background));
+
+            let thumb_offset = self.thumb.start * bounds.size.height;
+            let thumb_end = self.thumb.end * bounds.size.height;
+            let thumb_upper_left = point(bounds.origin.x, bounds.origin.y + thumb_offset);
+            let thumb_lower_right = point(
+                bounds.origin.x + bounds.size.width,
+                bounds.origin.y + thumb_end,
+            );
+            let thumb_percentage_size = self.thumb.end - self.thumb.start;
+            cx.paint_quad(gpui::fill(
+                Bounds::from_corners(thumb_upper_left, thumb_lower_right),
+                thumb_background,
+            ));
+            let scroll = self.scroll.clone();
+            let item_count = self.item_count;
+            cx.on_mouse_event({
+                let scroll = self.scroll.clone();
+                move |event: &MouseDownEvent, phase, _cx| {
+                    if phase.bubble() && bounds.contains(&event.position) {
+                        let scroll = scroll.0.borrow();
+                        if let Some(last_height) = scroll.last_item_height {
+                            let max_offset = item_count as f32 * last_height;
+                            let percentage =
+                                (event.position.y - bounds.origin.y) / bounds.size.height;
+
+                            let percentage = percentage.min(1. - thumb_percentage_size);
+                            scroll
+                                .base_handle
+                                .set_offset(point(px(0.), -max_offset * percentage));
+                        }
+                    }
+                }
+            });
+            cx.on_mouse_event({
+                let scroll = self.scroll.clone();
+                move |event: &ScrollWheelEvent, phase, cx| {
+                    if phase.bubble() && bounds.contains(&event.position) {
+                        let scroll = scroll.0.borrow_mut();
+                        let current_offset = scroll.base_handle.offset();
+                        scroll
+                            .base_handle
+                            .set_offset(current_offset + event.delta.pixel_delta(cx.line_height()));
+                    }
+                }
+            });
+
+            cx.on_mouse_event(move |event: &MouseMoveEvent, phase, cx| {
+                if phase.bubble() && bounds.contains(&event.position) && hitbox_id.is_hovered(cx) {
+                    if event.dragging() {
+                        let scroll = scroll.0.borrow();
+                        if let Some(last_height) = scroll.last_item_height {
+                            let max_offset = item_count as f32 * last_height;
+                            let percentage =
+                                (event.position.y - bounds.origin.y) / bounds.size.height;
+
+                            let percentage = percentage.min(1. - thumb_percentage_size);
+                            scroll
+                                .base_handle
+                                .set_offset(point(px(0.), -max_offset * percentage));
+                        }
+                    } else {
+                        cx.stop_propagation();
+                    }
+                }
+            });
+        })
+    }
+}
+
+impl IntoElement for ProjectPanelScrollbar {
+    type Element = Self;
+
+    fn into_element(self) -> Self::Element {
+        self
+    }
+}