ui: Add Scrollbar component (#18927)

Piotr Osiewicz created

Closes #ISSUE

Release Notes:

- N/A

Change summary

crates/gpui/src/elements/div.rs           |   5 
crates/project_panel/src/project_panel.rs | 114 +-----
crates/project_panel/src/scrollbar.rs     | 277 -----------------
crates/recent_projects/src/dev_servers.rs | 128 +++++--
crates/ui/src/components.rs               |   2 
crates/ui/src/components/scrollbar.rs     | 396 +++++++++++++++++++++++++
6 files changed, 513 insertions(+), 409 deletions(-)

Detailed changes

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

@@ -2575,4 +2575,9 @@ impl ScrollHandle {
     pub fn set_logical_scroll_top(&self, ix: usize, px: Pixels) {
         self.0.borrow_mut().requested_scroll_top = Some((ix, px));
     }
+
+    /// Get the count of children for scrollable item.
+    pub fn children_count(&self) -> usize {
+        self.0.borrow().child_bounds.len()
+    }
 }

crates/project_panel/src/project_panel.rs 🔗

@@ -1,8 +1,8 @@
 mod project_panel_settings;
-mod scrollbar;
+
 use client::{ErrorCode, ErrorExt};
-use scrollbar::ProjectPanelScrollbar;
 use settings::{Settings, SettingsStore};
+use ui::{Scrollbar, ScrollbarState};
 
 use db::kvp::KEY_VALUE_STORE;
 use editor::{
@@ -14,16 +14,14 @@ use file_icons::FileIcons;
 
 use anyhow::{anyhow, Context as _, Result};
 use collections::{hash_map, BTreeSet, HashMap};
-use core::f32;
 use git::repository::GitFileStatus;
 use gpui::{
     actions, anchored, deferred, div, impl_actions, px, uniform_list, Action, AnyElement,
     AppContext, AssetSource, AsyncWindowContext, ClipboardItem, DismissEvent, Div, DragMoveEvent,
-    Entity, EventEmitter, ExternalPaths, FocusHandle, FocusableView, InteractiveElement,
-    KeyContext, ListHorizontalSizingBehavior, ListSizingBehavior, Model, MouseButton,
-    MouseDownEvent, ParentElement, Pixels, Point, PromptLevel, Render, Stateful, Styled,
-    Subscription, Task, UniformListScrollHandle, View, ViewContext, VisualContext as _, WeakView,
-    WindowContext,
+    EventEmitter, ExternalPaths, FocusHandle, FocusableView, InteractiveElement, KeyContext,
+    ListHorizontalSizingBehavior, ListSizingBehavior, Model, MouseButton, MouseDownEvent,
+    ParentElement, Pixels, Point, PromptLevel, Render, Stateful, Styled, Subscription, Task,
+    UniformListScrollHandle, View, ViewContext, VisualContext as _, WeakView, WindowContext,
 };
 use indexmap::IndexMap;
 use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrev};
@@ -34,12 +32,11 @@ use project::{
 use project_panel_settings::{ProjectPanelDockPosition, ProjectPanelSettings};
 use serde::{Deserialize, Serialize};
 use std::{
-    cell::{Cell, OnceCell},
+    cell::OnceCell,
     collections::HashSet,
     ffi::OsStr,
     ops::Range,
     path::{Path, PathBuf},
-    rc::Rc,
     sync::Arc,
     time::Duration,
 };
@@ -59,8 +56,8 @@ const NEW_ENTRY_ID: ProjectEntryId = ProjectEntryId::MAX;
 pub struct ProjectPanel {
     project: Model<Project>,
     fs: Arc<dyn Fs>,
-    scroll_handle: UniformListScrollHandle,
     focus_handle: FocusHandle,
+    scroll_handle: UniformListScrollHandle,
     visible_entries: Vec<(WorktreeId, Vec<Entry>, OnceCell<HashSet<Arc<Path>>>)>,
     /// Maps from leaf project entry ID to the currently selected ancestor.
     /// Relevant only for auto-fold dirs, where a single project panel entry may actually consist of several
@@ -82,8 +79,8 @@ pub struct ProjectPanel {
     width: Option<Pixels>,
     pending_serialization: Task<Option<()>>,
     show_scrollbar: bool,
-    vertical_scrollbar_drag_thumb_offset: Rc<Cell<Option<f32>>>,
-    horizontal_scrollbar_drag_thumb_offset: Rc<Cell<Option<f32>>>,
+    vertical_scrollbar_state: ScrollbarState,
+    horizontal_scrollbar_state: ScrollbarState,
     hide_scrollbar_task: Option<Task<()>>,
     max_width_item_index: Option<usize>,
 }
@@ -297,10 +294,10 @@ impl ProjectPanel {
             })
             .detach();
 
+            let scroll_handle = UniformListScrollHandle::new();
             let mut this = Self {
                 project: project.clone(),
                 fs: workspace.app_state().fs.clone(),
-                scroll_handle: UniformListScrollHandle::new(),
                 focus_handle,
                 visible_entries: Default::default(),
                 ancestors: Default::default(),
@@ -320,9 +317,12 @@ impl ProjectPanel {
                 pending_serialization: Task::ready(None),
                 show_scrollbar: !Self::should_autohide_scrollbar(cx),
                 hide_scrollbar_task: None,
-                vertical_scrollbar_drag_thumb_offset: Default::default(),
-                horizontal_scrollbar_drag_thumb_offset: Default::default(),
+                vertical_scrollbar_state: ScrollbarState::new(scroll_handle.clone())
+                    .parent_view(cx.view()),
+                horizontal_scrollbar_state: ScrollbarState::new(scroll_handle.clone())
+                    .parent_view(cx.view()),
                 max_width_item_index: None,
+                scroll_handle,
             };
             this.update_visible_entries(None, cx);
 
@@ -2606,37 +2606,11 @@ impl ProjectPanel {
     }
 
     fn render_vertical_scrollbar(&self, cx: &mut ViewContext<Self>) -> Option<Stateful<Div>> {
-        if !Self::should_show_scrollbar(cx) {
-            return None;
-        }
-        let scroll_handle = self.scroll_handle.0.borrow();
-        let total_list_length = scroll_handle
-            .last_item_size
-            .filter(|_| {
-                self.show_scrollbar || self.vertical_scrollbar_drag_thumb_offset.get().is_some()
-            })?
-            .contents
-            .height
-            .0 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 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;
-        }
-        const MINIMUM_SCROLLBAR_PERCENTAGE_HEIGHT: f64 = 0.005;
-        if percentage + MINIMUM_SCROLLBAR_PERCENTAGE_HEIGHT > 1.0 || end_offset > total_list_length
+        if !Self::should_show_scrollbar(cx)
+            || !(self.show_scrollbar || self.vertical_scrollbar_state.is_dragging())
         {
             return None;
         }
-        if total_list_length < scroll_handle.base_handle.bounds().size.height.0 as f64 {
-            return None;
-        }
-        let end_offset = end_offset.clamp(percentage + MINIMUM_SCROLLBAR_PERCENTAGE_HEIGHT, 1.);
         Some(
             div()
                 .occlude()
@@ -2654,7 +2628,7 @@ impl ProjectPanel {
                 .on_mouse_up(
                     MouseButton::Left,
                     cx.listener(|this, _, cx| {
-                        if this.vertical_scrollbar_drag_thumb_offset.get().is_none()
+                        if !this.vertical_scrollbar_state.is_dragging()
                             && !this.focus_handle.contains_focused(cx)
                         {
                             this.hide_scrollbar(cx);
@@ -2674,48 +2648,20 @@ impl ProjectPanel {
                 .bottom_1()
                 .w(px(12.))
                 .cursor_default()
-                .child(ProjectPanelScrollbar::vertical(
-                    percentage as f32..end_offset as f32,
-                    self.scroll_handle.clone(),
-                    self.vertical_scrollbar_drag_thumb_offset.clone(),
-                    cx.view().entity_id(),
+                .children(Scrollbar::vertical(
+                    // percentage as f32..end_offset as f32,
+                    self.vertical_scrollbar_state.clone(),
                 )),
         )
     }
 
     fn render_horizontal_scrollbar(&self, cx: &mut ViewContext<Self>) -> Option<Stateful<Div>> {
-        if !Self::should_show_scrollbar(cx) {
-            return None;
-        }
-        let scroll_handle = self.scroll_handle.0.borrow();
-        let longest_item_width = scroll_handle
-            .last_item_size
-            .filter(|_| {
-                self.show_scrollbar || self.horizontal_scrollbar_drag_thumb_offset.get().is_some()
-            })
-            .filter(|size| size.contents.width > size.item.width)?
-            .contents
-            .width
-            .0 as f64;
-        let current_offset = scroll_handle.base_handle.offset().x.0.min(0.).abs() as f64;
-        let mut percentage = current_offset / longest_item_width;
-        let end_offset = (current_offset + scroll_handle.base_handle.bounds().size.width.0 as f64)
-            / longest_item_width;
-        // 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;
-        }
-        const MINIMUM_SCROLLBAR_PERCENTAGE_WIDTH: f64 = 0.005;
-        if percentage + MINIMUM_SCROLLBAR_PERCENTAGE_WIDTH > 1.0 || end_offset > longest_item_width
+        if !Self::should_show_scrollbar(cx)
+            || !(self.show_scrollbar || self.horizontal_scrollbar_state.is_dragging())
         {
             return None;
         }
-        if longest_item_width < scroll_handle.base_handle.bounds().size.width.0 as f64 {
-            return None;
-        }
-        let end_offset = end_offset.clamp(percentage + MINIMUM_SCROLLBAR_PERCENTAGE_WIDTH, 1.);
+
         Some(
             div()
                 .occlude()
@@ -2733,7 +2679,7 @@ impl ProjectPanel {
                 .on_mouse_up(
                     MouseButton::Left,
                     cx.listener(|this, _, cx| {
-                        if this.horizontal_scrollbar_drag_thumb_offset.get().is_none()
+                        if !this.horizontal_scrollbar_state.is_dragging()
                             && !this.focus_handle.contains_focused(cx)
                         {
                             this.hide_scrollbar(cx);
@@ -2754,11 +2700,9 @@ impl ProjectPanel {
                 .h(px(12.))
                 .cursor_default()
                 .when(self.width.is_some(), |this| {
-                    this.child(ProjectPanelScrollbar::horizontal(
-                        percentage as f32..end_offset as f32,
-                        self.scroll_handle.clone(),
-                        self.horizontal_scrollbar_drag_thumb_offset.clone(),
-                        cx.view().entity_id(),
+                    this.children(Scrollbar::horizontal(
+                        //percentage as f32..end_offset as f32,
+                        self.horizontal_scrollbar_state.clone(),
                     ))
                 }),
         )

crates/project_panel/src/scrollbar.rs 🔗

@@ -1,277 +0,0 @@
-use std::{cell::Cell, ops::Range, rc::Rc};
-
-use gpui::{
-    point, quad, Bounds, ContentMask, Corners, Edges, EntityId, Hitbox, Hsla, MouseDownEvent,
-    MouseMoveEvent, MouseUpEvent, ScrollWheelEvent, Style, UniformListScrollHandle,
-};
-use ui::{prelude::*, px, relative, IntoElement};
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub(crate) enum ScrollbarKind {
-    Horizontal,
-    Vertical,
-}
-
-pub(crate) struct ProjectPanelScrollbar {
-    thumb: Range<f32>,
-    scroll: UniformListScrollHandle,
-    // If Some(), there's an active drag, offset by percentage from the top of thumb.
-    scrollbar_drag_state: Rc<Cell<Option<f32>>>,
-    kind: ScrollbarKind,
-    parent_id: EntityId,
-}
-
-impl ProjectPanelScrollbar {
-    pub(crate) fn vertical(
-        thumb: Range<f32>,
-        scroll: UniformListScrollHandle,
-        scrollbar_drag_state: Rc<Cell<Option<f32>>>,
-        parent_id: EntityId,
-    ) -> Self {
-        Self {
-            thumb,
-            scroll,
-            scrollbar_drag_state,
-            kind: ScrollbarKind::Vertical,
-            parent_id,
-        }
-    }
-
-    pub(crate) fn horizontal(
-        thumb: Range<f32>,
-        scroll: UniformListScrollHandle,
-        scrollbar_drag_state: Rc<Cell<Option<f32>>>,
-        parent_id: EntityId,
-    ) -> Self {
-        Self {
-            thumb,
-            scroll,
-            scrollbar_drag_state,
-            kind: ScrollbarKind::Horizontal,
-            parent_id,
-        }
-    }
-}
-
-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.;
-        if self.kind == ScrollbarKind::Vertical {
-            style.size.width = px(12.).into();
-            style.size.height = relative(1.).into();
-        } else {
-            style.size.width = relative(1.).into();
-            style.size.height = px(12.).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,
-    ) {
-        cx.with_content_mask(Some(ContentMask { bounds }), |cx| {
-            let colors = cx.theme().colors();
-            let thumb_background = colors.scrollbar_thumb_background;
-            let is_vertical = self.kind == ScrollbarKind::Vertical;
-            let extra_padding = px(5.0);
-            let padded_bounds = if is_vertical {
-                Bounds::from_corners(
-                    bounds.origin + point(Pixels::ZERO, extra_padding),
-                    bounds.lower_right() - point(Pixels::ZERO, extra_padding * 3),
-                )
-            } else {
-                Bounds::from_corners(
-                    bounds.origin + point(extra_padding, Pixels::ZERO),
-                    bounds.lower_right() - point(extra_padding * 3, Pixels::ZERO),
-                )
-            };
-
-            let mut thumb_bounds = if is_vertical {
-                let thumb_offset = self.thumb.start * padded_bounds.size.height;
-                let thumb_end = self.thumb.end * padded_bounds.size.height;
-                let thumb_upper_left = point(
-                    padded_bounds.origin.x,
-                    padded_bounds.origin.y + thumb_offset,
-                );
-                let thumb_lower_right = point(
-                    padded_bounds.origin.x + padded_bounds.size.width,
-                    padded_bounds.origin.y + thumb_end,
-                );
-                Bounds::from_corners(thumb_upper_left, thumb_lower_right)
-            } else {
-                let thumb_offset = self.thumb.start * padded_bounds.size.width;
-                let thumb_end = self.thumb.end * padded_bounds.size.width;
-                let thumb_upper_left = point(
-                    padded_bounds.origin.x + thumb_offset,
-                    padded_bounds.origin.y,
-                );
-                let thumb_lower_right = point(
-                    padded_bounds.origin.x + thumb_end,
-                    padded_bounds.origin.y + padded_bounds.size.height,
-                );
-                Bounds::from_corners(thumb_upper_left, thumb_lower_right)
-            };
-            let corners = if is_vertical {
-                thumb_bounds.size.width /= 1.5;
-                Corners::all(thumb_bounds.size.width / 2.0)
-            } else {
-                thumb_bounds.size.height /= 1.5;
-                Corners::all(thumb_bounds.size.height / 2.0)
-            };
-            cx.paint_quad(quad(
-                thumb_bounds,
-                corners,
-                thumb_background,
-                Edges::default(),
-                Hsla::transparent_black(),
-            ));
-
-            let scroll = self.scroll.clone();
-            let kind = self.kind;
-            let thumb_percentage_size = self.thumb.end - self.thumb.start;
-
-            cx.on_mouse_event({
-                let scroll = self.scroll.clone();
-                let is_dragging = self.scrollbar_drag_state.clone();
-                move |event: &MouseDownEvent, phase, _cx| {
-                    if phase.bubble() && bounds.contains(&event.position) {
-                        if !thumb_bounds.contains(&event.position) {
-                            let scroll = scroll.0.borrow();
-                            if let Some(item_size) = scroll.last_item_size {
-                                match kind {
-                                    ScrollbarKind::Horizontal => {
-                                        let percentage = (event.position.x - bounds.origin.x)
-                                            / bounds.size.width;
-                                        let max_offset = item_size.contents.width;
-                                        let percentage = percentage.min(1. - thumb_percentage_size);
-                                        scroll.base_handle.set_offset(point(
-                                            -max_offset * percentage,
-                                            scroll.base_handle.offset().y,
-                                        ));
-                                    }
-                                    ScrollbarKind::Vertical => {
-                                        let percentage = (event.position.y - bounds.origin.y)
-                                            / bounds.size.height;
-                                        let max_offset = item_size.contents.height;
-                                        let percentage = percentage.min(1. - thumb_percentage_size);
-                                        scroll.base_handle.set_offset(point(
-                                            scroll.base_handle.offset().x,
-                                            -max_offset * percentage,
-                                        ));
-                                    }
-                                }
-                            }
-                        } else {
-                            let thumb_offset = if is_vertical {
-                                (event.position.y - thumb_bounds.origin.y) / bounds.size.height
-                            } else {
-                                (event.position.x - thumb_bounds.origin.x) / bounds.size.width
-                            };
-                            is_dragging.set(Some(thumb_offset));
-                        }
-                    }
-                }
-            });
-            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()));
-                    }
-                }
-            });
-            let drag_state = self.scrollbar_drag_state.clone();
-            let view_id = self.parent_id;
-            let kind = self.kind;
-            cx.on_mouse_event(move |event: &MouseMoveEvent, _, cx| {
-                if let Some(drag_state) = drag_state.get().filter(|_| event.dragging()) {
-                    let scroll = scroll.0.borrow();
-                    if let Some(item_size) = scroll.last_item_size {
-                        match kind {
-                            ScrollbarKind::Horizontal => {
-                                let max_offset = item_size.contents.width;
-                                let percentage = (event.position.x - bounds.origin.x)
-                                    / bounds.size.width
-                                    - drag_state;
-
-                                let percentage = percentage.min(1. - thumb_percentage_size);
-                                scroll.base_handle.set_offset(point(
-                                    -max_offset * percentage,
-                                    scroll.base_handle.offset().y,
-                                ));
-                            }
-                            ScrollbarKind::Vertical => {
-                                let max_offset = item_size.contents.height;
-                                let percentage = (event.position.y - bounds.origin.y)
-                                    / bounds.size.height
-                                    - drag_state;
-
-                                let percentage = percentage.min(1. - thumb_percentage_size);
-                                scroll.base_handle.set_offset(point(
-                                    scroll.base_handle.offset().x,
-                                    -max_offset * percentage,
-                                ));
-                            }
-                        };
-
-                        cx.notify(view_id);
-                    }
-                } else {
-                    drag_state.set(None);
-                }
-            });
-            let is_dragging = self.scrollbar_drag_state.clone();
-            cx.on_mouse_event(move |_event: &MouseUpEvent, phase, cx| {
-                if phase.bubble() {
-                    is_dragging.set(None);
-                    cx.notify(view_id);
-                }
-            });
-        })
-    }
-}
-
-impl IntoElement for ProjectPanelScrollbar {
-    type Element = Self;
-
-    fn into_element(self) -> Self::Element {
-        self
-    }
-}

crates/recent_projects/src/dev_servers.rs 🔗

@@ -34,6 +34,8 @@ use task::HideStrategy;
 use task::RevealStrategy;
 use task::SpawnInTerminal;
 use terminal_view::terminal_panel::TerminalPanel;
+use ui::Scrollbar;
+use ui::ScrollbarState;
 use ui::Section;
 use ui::{prelude::*, IconButtonShape, List, ListItem, ListSeparator, Modal, ModalHeader, Tooltip};
 use util::ResultExt;
@@ -301,13 +303,19 @@ impl gpui::Render for ProjectPicker {
     }
 }
 enum Mode {
-    Default,
+    Default(ScrollbarState),
     ViewServerOptions(usize, SshConnection),
     EditNickname(EditNicknameState),
     ProjectPicker(View<ProjectPicker>),
     CreateDevServer(CreateDevServer),
 }
 
+impl Mode {
+    fn default_mode() -> Self {
+        let handle = ScrollHandle::new();
+        Self::Default(ScrollbarState::new(handle))
+    }
+}
 impl DevServerProjects {
     pub fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
         workspace.register_action(|workspace, _: &OpenRemote, cx| {
@@ -338,7 +346,7 @@ impl DevServerProjects {
         });
 
         Self {
-            mode: Mode::Default,
+            mode: Mode::default_mode(),
             focus_handle,
             scroll_handle: ScrollHandle::new(),
             dev_server_store,
@@ -349,13 +357,13 @@ impl DevServerProjects {
     }
 
     fn next_item(&mut self, _: &menu::SelectNext, cx: &mut ViewContext<Self>) {
-        if !matches!(self.mode, Mode::Default | Mode::ViewServerOptions(_, _)) {
+        if !matches!(self.mode, Mode::Default(_) | Mode::ViewServerOptions(_, _)) {
             return;
         }
         self.selectable_items.next(cx);
     }
     fn prev_item(&mut self, _: &menu::SelectPrev, cx: &mut ViewContext<Self>) {
-        if !matches!(self.mode, Mode::Default | Mode::ViewServerOptions(_, _)) {
+        if !matches!(self.mode, Mode::Default(_) | Mode::ViewServerOptions(_, _)) {
             return;
         }
         self.selectable_items.prev(cx);
@@ -431,7 +439,7 @@ impl DevServerProjects {
                         });
 
                         this.add_ssh_server(connection_options, cx);
-                        this.mode = Mode::Default;
+                        this.mode = Mode::default_mode();
                         this.selectable_items.reset_selection();
                         cx.notify()
                     })
@@ -535,7 +543,7 @@ impl DevServerProjects {
 
     fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
         match &self.mode {
-            Mode::Default | Mode::ViewServerOptions(_, _) => {
+            Mode::Default(_) | Mode::ViewServerOptions(_, _) => {
                 let items = std::mem::take(&mut self.selectable_items);
                 items.confirm(self, cx);
                 self.selectable_items = items;
@@ -566,7 +574,7 @@ impl DevServerProjects {
                         }
                     }
                 });
-                self.mode = Mode::Default;
+                self.mode = Mode::default_mode();
                 self.selectable_items.reset_selection();
                 self.focus_handle.focus(cx);
             }
@@ -575,14 +583,14 @@ impl DevServerProjects {
 
     fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
         match &self.mode {
-            Mode::Default => cx.emit(DismissEvent),
+            Mode::Default(_) => cx.emit(DismissEvent),
             Mode::CreateDevServer(state) if state.ssh_prompt.is_some() => {
                 self.mode = Mode::CreateDevServer(CreateDevServer::new(cx));
                 self.selectable_items.reset_selection();
                 cx.notify();
             }
             _ => {
-                self.mode = Mode::Default;
+                self.mode = Mode::default_mode();
                 self.selectable_items.reset_selection();
                 self.focus_handle(cx).focus(cx);
                 cx.notify();
@@ -1012,7 +1020,7 @@ impl DevServerProjects {
                                             move |cx| {
                                                 dev_servers.update(cx, |this, cx| {
                                                     this.delete_ssh_server(index, cx);
-                                                    this.mode = Mode::Default;
+                                                    this.mode = Mode::default_mode();
                                                     cx.notify();
                                                 })
                                             },
@@ -1055,7 +1063,7 @@ impl DevServerProjects {
                     .child({
                         self.selectable_items.add_item(Box::new({
                             move |this, cx| {
-                                this.mode = Mode::Default;
+                                this.mode = Mode::default_mode();
                                 cx.notify();
                             }
                         }));
@@ -1067,7 +1075,7 @@ impl DevServerProjects {
                             .start_slot(Icon::new(IconName::ArrowLeft).color(Color::Muted))
                             .child(Label::new("Go Back"))
                             .on_click(cx.listener(|this, _, cx| {
-                                this.mode = Mode::Default;
+                                this.mode = Mode::default_mode();
                                 cx.notify()
                             }))
                     }),
@@ -1099,7 +1107,12 @@ impl DevServerProjects {
             .child(h_flex().p_2().child(state.editor.clone()))
     }
 
-    fn render_default(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
+    fn render_default(
+        &mut self,
+        scroll_state: ScrollbarState,
+        cx: &mut ViewContext<Self>,
+    ) -> impl IntoElement {
+        let scroll_state = scroll_state.parent_view(cx.view());
         let dev_servers = self.dev_server_store.read(cx).dev_servers();
         let ssh_connections = SshSettings::get_global(cx)
             .ssh_connections()
@@ -1124,27 +1137,37 @@ impl DevServerProjects {
                 cx.notify();
             }));
 
+        let ui::ScrollableHandle::NonUniform(scroll_handle) = scroll_state.scroll_handle() else {
+            unreachable!()
+        };
+
         let mut modal_section = v_flex()
             .id("ssh-server-list")
             .overflow_y_scroll()
+            .track_scroll(&scroll_handle)
             .size_full()
             .child(connect_button)
             .child(
-                List::new()
-                    .empty_message(
-                        v_flex()
-                            .child(ListSeparator)
-                            .child(div().px_3().child(
-                                Label::new("No dev servers registered yet.").color(Color::Muted),
-                            ))
-                            .into_any_element(),
-                    )
-                    .children(ssh_connections.iter().cloned().enumerate().map(
-                        |(ix, connection)| {
-                            self.render_ssh_connection(ix, connection, cx)
-                                .into_any_element()
-                        },
-                    )),
+                h_flex().child(
+                    List::new()
+                        .empty_message(
+                            v_flex()
+                                .child(ListSeparator)
+                                .child(
+                                    div().px_3().child(
+                                        Label::new("No dev servers registered yet.")
+                                            .color(Color::Muted),
+                                    ),
+                                )
+                                .into_any_element(),
+                        )
+                        .children(ssh_connections.iter().cloned().enumerate().map(
+                            |(ix, connection)| {
+                                self.render_ssh_connection(ix, connection, cx)
+                                    .into_any_element()
+                            },
+                        )),
+                ),
             )
             .into_any_element();
 
@@ -1162,26 +1185,37 @@ impl DevServerProjects {
             )
             .section(
                 Section::new().padded(false).child(
-                    v_flex()
+                    h_flex()
                         .min_h(rems(20.))
-                        .flex_1()
                         .size_full()
-                        .child(ListSeparator)
                         .child(
-                            canvas(
-                                |bounds, cx| {
-                                    modal_section.prepaint_as_root(
-                                        bounds.origin,
-                                        bounds.size.into(),
-                                        cx,
-                                    );
-                                    modal_section
-                                },
-                                |_, mut modal_section, cx| {
-                                    modal_section.paint(cx);
-                                },
-                            )
-                            .size_full(),
+                            v_flex().size_full().child(ListSeparator).child(
+                                canvas(
+                                    |bounds, cx| {
+                                        modal_section.prepaint_as_root(
+                                            bounds.origin,
+                                            bounds.size.into(),
+                                            cx,
+                                        );
+                                        modal_section
+                                    },
+                                    |_, mut modal_section, cx| {
+                                        modal_section.paint(cx);
+                                    },
+                                )
+                                .size_full(),
+                            ),
+                        )
+                        .child(
+                            div()
+                                .occlude()
+                                .h_full()
+                                .absolute()
+                                .right_1()
+                                .top_1()
+                                .bottom_1()
+                                .w(px(12.))
+                                .children(Scrollbar::vertical(scroll_state)),
                         ),
                 ),
             )
@@ -1217,13 +1251,13 @@ impl Render for DevServerProjects {
                 this.focus_handle(cx).focus(cx);
             }))
             .on_mouse_down_out(cx.listener(|this, _, cx| {
-                if matches!(this.mode, Mode::Default) {
+                if matches!(this.mode, Mode::Default(_)) {
                     cx.emit(DismissEvent)
                 }
             }))
             .w(rems(34.))
             .child(match &self.mode {
-                Mode::Default => self.render_default(cx).into_any_element(),
+                Mode::Default(state) => self.render_default(state.clone(), cx).into_any_element(),
                 Mode::ViewServerOptions(index, connection) => self
                     .render_view_options(*index, connection.clone(), cx)
                     .into_any_element(),

crates/ui/src/components.rs 🔗

@@ -18,6 +18,7 @@ mod popover;
 mod popover_menu;
 mod radio;
 mod right_click_menu;
+mod scrollbar;
 mod settings_container;
 mod settings_group;
 mod stack;
@@ -49,6 +50,7 @@ pub use popover::*;
 pub use popover_menu::*;
 pub use radio::*;
 pub use right_click_menu::*;
+pub use scrollbar::*;
 pub use settings_container::*;
 pub use settings_group::*;
 pub use stack::*;

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

@@ -0,0 +1,396 @@
+#![allow(missing_docs)]
+use std::{cell::Cell, ops::Range, rc::Rc};
+
+use crate::{prelude::*, px, relative, IntoElement};
+use gpui::{
+    point, quad, Along, Axis as ScrollbarAxis, Bounds, ContentMask, Corners, Edges, Element,
+    ElementId, Entity, EntityId, GlobalElementId, Hitbox, Hsla, LayoutId, MouseDownEvent,
+    MouseMoveEvent, MouseUpEvent, Pixels, Point, ScrollHandle, ScrollWheelEvent, Size, Style,
+    UniformListScrollHandle, View, WindowContext,
+};
+
+pub struct Scrollbar {
+    thumb: Range<f32>,
+    state: ScrollbarState,
+    kind: ScrollbarAxis,
+}
+
+/// Wrapper around scroll handles.
+#[derive(Clone)]
+pub enum ScrollableHandle {
+    Uniform(UniformListScrollHandle),
+    NonUniform(ScrollHandle),
+}
+
+#[derive(Debug)]
+struct ContentSize {
+    size: Size<Pixels>,
+    scroll_adjustment: Option<Point<Pixels>>,
+}
+
+impl ScrollableHandle {
+    fn content_size(&self) -> Option<ContentSize> {
+        match self {
+            ScrollableHandle::Uniform(handle) => Some(ContentSize {
+                size: handle.0.borrow().last_item_size.map(|size| size.contents)?,
+                scroll_adjustment: None,
+            }),
+            ScrollableHandle::NonUniform(handle) => {
+                let last_children_index = handle.children_count().checked_sub(1)?;
+                // todo: PO: this is slightly wrong for horizontal scrollbar, as the last item is not necessarily the longest one.
+                let mut last_item = handle.bounds_for_item(last_children_index)?;
+                last_item.size.height += last_item.origin.y;
+                last_item.size.width += last_item.origin.x;
+                let mut scroll_adjustment = None;
+                if last_children_index != 0 {
+                    let first_item = handle.bounds_for_item(0)?;
+
+                    scroll_adjustment = Some(first_item.origin);
+                    last_item.size.height -= first_item.origin.y;
+                    last_item.size.width -= first_item.origin.x;
+                }
+                Some(ContentSize {
+                    size: last_item.size,
+                    scroll_adjustment,
+                })
+            }
+        }
+    }
+    fn set_offset(&self, point: Point<Pixels>) {
+        let base_handle = match self {
+            ScrollableHandle::Uniform(handle) => &handle.0.borrow().base_handle,
+            ScrollableHandle::NonUniform(handle) => &handle,
+        };
+        base_handle.set_offset(point);
+    }
+    fn offset(&self) -> Point<Pixels> {
+        let base_handle = match self {
+            ScrollableHandle::Uniform(handle) => &handle.0.borrow().base_handle,
+            ScrollableHandle::NonUniform(handle) => &handle,
+        };
+        base_handle.offset()
+    }
+    fn viewport(&self) -> Bounds<Pixels> {
+        let base_handle = match self {
+            ScrollableHandle::Uniform(handle) => &handle.0.borrow().base_handle,
+            ScrollableHandle::NonUniform(handle) => &handle,
+        };
+        base_handle.bounds()
+    }
+}
+impl From<UniformListScrollHandle> for ScrollableHandle {
+    fn from(value: UniformListScrollHandle) -> Self {
+        Self::Uniform(value)
+    }
+}
+
+impl From<ScrollHandle> for ScrollableHandle {
+    fn from(value: ScrollHandle) -> Self {
+        Self::NonUniform(value)
+    }
+}
+
+/// A scrollbar state that should be persisted across frames.
+#[derive(Clone)]
+pub struct ScrollbarState {
+    // If Some(), there's an active drag, offset by percentage from the origin of a thumb.
+    drag: Rc<Cell<Option<f32>>>,
+    parent_id: Option<EntityId>,
+    scroll_handle: ScrollableHandle,
+}
+
+impl ScrollbarState {
+    pub fn new(scroll: impl Into<ScrollableHandle>) -> Self {
+        Self {
+            drag: Default::default(),
+            parent_id: None,
+            scroll_handle: scroll.into(),
+        }
+    }
+
+    /// Set a parent view which should be notified whenever this Scrollbar gets a scroll event.
+    pub fn parent_view<V: 'static>(mut self, v: &View<V>) -> Self {
+        self.parent_id = Some(v.entity_id());
+        self
+    }
+
+    pub fn scroll_handle(&self) -> ScrollableHandle {
+        self.scroll_handle.clone()
+    }
+
+    pub fn is_dragging(&self) -> bool {
+        self.drag.get().is_some()
+    }
+
+    fn thumb_range(&self, axis: ScrollbarAxis) -> Option<Range<f32>> {
+        const MINIMUM_SCROLLBAR_PERCENTAGE_SIZE: f32 = 0.005;
+        let ContentSize {
+            size: main_dimension_size,
+            scroll_adjustment,
+        } = self.scroll_handle.content_size()?;
+        let main_dimension_size = main_dimension_size.along(axis).0;
+        let mut current_offset = self.scroll_handle.offset().along(axis).min(px(0.)).abs().0;
+        if let Some(adjustment) = scroll_adjustment.and_then(|adjustment| {
+            let adjust = adjustment.along(axis).0;
+            if adjust < 0.0 {
+                Some(adjust)
+            } else {
+                None
+            }
+        }) {
+            current_offset -= adjustment;
+        }
+        let mut percentage = current_offset / main_dimension_size;
+        let viewport_size = self.scroll_handle.viewport().size;
+
+        let end_offset = (current_offset + viewport_size.along(axis).0) / main_dimension_size;
+        // 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 + MINIMUM_SCROLLBAR_PERCENTAGE_SIZE > 1.0 || end_offset > main_dimension_size
+        {
+            return None;
+        }
+        if main_dimension_size < viewport_size.along(axis).0 {
+            return None;
+        }
+        let end_offset = end_offset.clamp(percentage + MINIMUM_SCROLLBAR_PERCENTAGE_SIZE, 1.);
+        Some(percentage..end_offset)
+    }
+}
+
+impl Scrollbar {
+    pub fn vertical(state: ScrollbarState) -> Option<Self> {
+        Self::new(state, ScrollbarAxis::Vertical)
+    }
+
+    pub fn horizontal(state: ScrollbarState) -> Option<Self> {
+        Self::new(state, ScrollbarAxis::Horizontal)
+    }
+    fn new(state: ScrollbarState, kind: ScrollbarAxis) -> Option<Self> {
+        let thumb = state.thumb_range(kind)?;
+        Some(Self { thumb, state, kind })
+    }
+}
+
+impl Element for Scrollbar {
+    type RequestLayoutState = ();
+
+    type PrepaintState = Hitbox;
+
+    fn id(&self) -> Option<ElementId> {
+        None
+    }
+
+    fn request_layout(
+        &mut self,
+        _id: Option<&GlobalElementId>,
+        cx: &mut WindowContext,
+    ) -> (LayoutId, Self::RequestLayoutState) {
+        let mut style = Style::default();
+        style.flex_grow = 1.;
+        style.flex_shrink = 1.;
+
+        if self.kind == ScrollbarAxis::Vertical {
+            style.size.width = px(12.).into();
+            style.size.height = relative(1.).into();
+        } else {
+            style.size.width = relative(1.).into();
+            style.size.height = px(12.).into();
+        }
+
+        (cx.request_layout(style, None), ())
+    }
+
+    fn prepaint(
+        &mut self,
+        _id: Option<&GlobalElementId>,
+        bounds: Bounds<Pixels>,
+        _request_layout: &mut Self::RequestLayoutState,
+        cx: &mut WindowContext,
+    ) -> Self::PrepaintState {
+        cx.with_content_mask(Some(ContentMask { bounds }), |cx| {
+            cx.insert_hitbox(bounds, false)
+        })
+    }
+
+    fn paint(
+        &mut self,
+        _id: Option<&GlobalElementId>,
+        bounds: Bounds<Pixels>,
+        _request_layout: &mut Self::RequestLayoutState,
+        _prepaint: &mut Self::PrepaintState,
+        cx: &mut WindowContext,
+    ) {
+        cx.with_content_mask(Some(ContentMask { bounds }), |cx| {
+            let colors = cx.theme().colors();
+            let thumb_background = colors.scrollbar_thumb_background;
+            let is_vertical = self.kind == ScrollbarAxis::Vertical;
+            let extra_padding = px(5.0);
+            let padded_bounds = if is_vertical {
+                Bounds::from_corners(
+                    bounds.origin + point(Pixels::ZERO, extra_padding),
+                    bounds.lower_right() - point(Pixels::ZERO, extra_padding * 3),
+                )
+            } else {
+                Bounds::from_corners(
+                    bounds.origin + point(extra_padding, Pixels::ZERO),
+                    bounds.lower_right() - point(extra_padding * 3, Pixels::ZERO),
+                )
+            };
+
+            let mut thumb_bounds = if is_vertical {
+                let thumb_offset = self.thumb.start * padded_bounds.size.height;
+                let thumb_end = self.thumb.end * padded_bounds.size.height;
+                let thumb_upper_left = point(
+                    padded_bounds.origin.x,
+                    padded_bounds.origin.y + thumb_offset,
+                );
+                let thumb_lower_right = point(
+                    padded_bounds.origin.x + padded_bounds.size.width,
+                    padded_bounds.origin.y + thumb_end,
+                );
+                Bounds::from_corners(thumb_upper_left, thumb_lower_right)
+            } else {
+                let thumb_offset = self.thumb.start * padded_bounds.size.width;
+                let thumb_end = self.thumb.end * padded_bounds.size.width;
+                let thumb_upper_left = point(
+                    padded_bounds.origin.x + thumb_offset,
+                    padded_bounds.origin.y,
+                );
+                let thumb_lower_right = point(
+                    padded_bounds.origin.x + thumb_end,
+                    padded_bounds.origin.y + padded_bounds.size.height,
+                );
+                Bounds::from_corners(thumb_upper_left, thumb_lower_right)
+            };
+            let corners = if is_vertical {
+                thumb_bounds.size.width /= 1.5;
+                Corners::all(thumb_bounds.size.width / 2.0)
+            } else {
+                thumb_bounds.size.height /= 1.5;
+                Corners::all(thumb_bounds.size.height / 2.0)
+            };
+            cx.paint_quad(quad(
+                thumb_bounds,
+                corners,
+                thumb_background,
+                Edges::default(),
+                Hsla::transparent_black(),
+            ));
+
+            let scroll = self.state.scroll_handle.clone();
+            let kind = self.kind;
+            let thumb_percentage_size = self.thumb.end - self.thumb.start;
+
+            cx.on_mouse_event({
+                let scroll = scroll.clone();
+                let state = self.state.clone();
+                let axis = self.kind;
+                move |event: &MouseDownEvent, phase, _cx| {
+                    if !(phase.bubble() && bounds.contains(&event.position)) {
+                        return;
+                    }
+
+                    if thumb_bounds.contains(&event.position) {
+                        let thumb_offset = (event.position.along(axis)
+                            - thumb_bounds.origin.along(axis))
+                            / bounds.size.along(axis);
+                        state.drag.set(Some(thumb_offset));
+                    } else if let Some(ContentSize {
+                        size: item_size, ..
+                    }) = scroll.content_size()
+                    {
+                        match kind {
+                            ScrollbarAxis::Horizontal => {
+                                let percentage =
+                                    (event.position.x - bounds.origin.x) / bounds.size.width;
+                                let max_offset = item_size.width;
+                                let percentage = percentage.min(1. - thumb_percentage_size);
+                                scroll
+                                    .set_offset(point(-max_offset * percentage, scroll.offset().y));
+                            }
+                            ScrollbarAxis::Vertical => {
+                                let percentage =
+                                    (event.position.y - bounds.origin.y) / bounds.size.height;
+                                let max_offset = item_size.height;
+                                let percentage = percentage.min(1. - thumb_percentage_size);
+                                scroll
+                                    .set_offset(point(scroll.offset().x, -max_offset * percentage));
+                            }
+                        }
+                    }
+                }
+            });
+            cx.on_mouse_event({
+                let scroll = scroll.clone();
+                move |event: &ScrollWheelEvent, phase, cx| {
+                    if phase.bubble() && bounds.contains(&event.position) {
+                        let current_offset = scroll.offset();
+                        scroll
+                            .set_offset(current_offset + event.delta.pixel_delta(cx.line_height()));
+                    }
+                }
+            });
+            let state = self.state.clone();
+            let kind = self.kind;
+            cx.on_mouse_event(move |event: &MouseMoveEvent, _, cx| {
+                if let Some(drag_state) = state.drag.get().filter(|_| event.dragging()) {
+                    if let Some(ContentSize {
+                        size: item_size, ..
+                    }) = scroll.content_size()
+                    {
+                        match kind {
+                            ScrollbarAxis::Horizontal => {
+                                let max_offset = item_size.width;
+                                let percentage = (event.position.x - bounds.origin.x)
+                                    / bounds.size.width
+                                    - drag_state;
+
+                                let percentage = percentage.min(1. - thumb_percentage_size);
+                                scroll
+                                    .set_offset(point(-max_offset * percentage, scroll.offset().y));
+                            }
+                            ScrollbarAxis::Vertical => {
+                                let max_offset = item_size.height;
+                                let percentage = (event.position.y - bounds.origin.y)
+                                    / bounds.size.height
+                                    - drag_state;
+
+                                let percentage = percentage.min(1. - thumb_percentage_size);
+                                scroll
+                                    .set_offset(point(scroll.offset().x, -max_offset * percentage));
+                            }
+                        };
+
+                        if let Some(id) = state.parent_id {
+                            cx.notify(id);
+                        }
+                    }
+                } else {
+                    state.drag.set(None);
+                }
+            });
+            let state = self.state.clone();
+            cx.on_mouse_event(move |_event: &MouseUpEvent, phase, cx| {
+                if phase.bubble() {
+                    state.drag.take();
+                    if let Some(id) = state.parent_id {
+                        cx.notify(id);
+                    }
+                }
+            });
+        })
+    }
+}
+
+impl IntoElement for Scrollbar {
+    type Element = Self;
+
+    fn into_element(self) -> Self::Element {
+        self
+    }
+}