ui: Implement graceful autohiding for scrollbars (#39225)

Finn Evers and Danilo Leal created

How it looks:


https://github.com/user-attachments/assets/9a355807-5461-4e8d-b7a8-9efb98cea67a

Idea behind this is to reduce flickering in areas where nothing is
happening - whenever these hide, the user is specifically not
interacting with them, hence it can be distracting to have something
flicker in the side of your eye. This PR tackles this.


Release Notes:

- Added graceful autohiding to scrollbars outside of the editor

---------

Co-authored-by: Danilo Leal <67129314+danilo-leal@users.noreply.github.com>

Change summary

crates/editor/src/signature_help.rs   |   2 
crates/ui/src/components/scrollbar.rs | 274 +++++++++++++++++++++-------
2 files changed, 200 insertions(+), 76 deletions(-)

Detailed changes

crates/editor/src/signature_help.rs 🔗

@@ -389,7 +389,7 @@ impl SignatureHelpPopover {
                             )
                     }),
             )
-            .vertical_scrollbar(window, cx);
+            .vertical_scrollbar_for(self.scroll_handle.clone(), window, cx);
 
         let controls = if self.signatures.len() > 1 {
             let prev_button = IconButton::new("signature_help_prev", IconName::ChevronUp)

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

@@ -1,4 +1,9 @@
-use std::{any::Any, fmt::Debug, ops::Not, time::Duration};
+use std::{
+    any::Any,
+    fmt::Debug,
+    ops::Not,
+    time::{Duration, Instant},
+};
 
 use gpui::{
     Along, App, AppContext as _, Axis as ScrollbarAxis, BorderStyle, Bounds, ContentMask, Context,
@@ -7,7 +12,8 @@ use gpui::{
     LayoutId, ListState, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Negate,
     ParentElement, Pixels, Point, Position, Render, ScrollHandle, ScrollWheelEvent, Size, Stateful,
     StatefulInteractiveElement, Style, Styled, Task, UniformListDecoration,
-    UniformListScrollHandle, Window, prelude::FluentBuilder as _, px, quad, relative, size,
+    UniformListScrollHandle, Window, ease_in_out, prelude::FluentBuilder as _, px, quad, relative,
+    size,
 };
 use settings::SettingsStore;
 use smallvec::SmallVec;
@@ -16,9 +22,12 @@ use util::ResultExt;
 
 use std::ops::Range;
 
-use crate::scrollbars::{ScrollbarVisibility, ShowScrollbar};
+use crate::scrollbars::{ScrollbarAutoHide, ScrollbarVisibility, ShowScrollbar};
+
+const SCROLLBAR_HIDE_DELAY_INTERVAL: Duration = Duration::from_secs(1);
+const SCROLLBAR_HIDE_DURATION: Duration = Duration::from_millis(400);
+const SCROLLBAR_SHOW_DURATION: Duration = Duration::from_millis(50);
 
-const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_millis(1500);
 const SCROLLBAR_PADDING: Pixels = px(4.);
 
 pub mod scrollbars {
@@ -56,20 +65,6 @@ pub mod scrollbars {
         }
     }
 
-    impl ShowScrollbar {
-        pub(super) fn show(&self) -> bool {
-            *self != Self::Never
-        }
-
-        pub(super) fn should_auto_hide(&self, cx: &mut App) -> bool {
-            match self {
-                Self::Auto => true,
-                Self::System => cx.default_global::<ScrollbarAutoHide>().should_hide(),
-                _ => false,
-            }
-        }
-    }
-
     pub trait GlobalSetting {
         fn get_value(cx: &App) -> &Self;
     }
@@ -127,23 +122,24 @@ pub trait WithScrollbar: Sized {
     where
         T: ScrollableHandle;
 
-    #[track_caller]
-    fn horizontal_scrollbar(self, window: &mut Window, cx: &mut App) -> Self::Output {
-        self.custom_scrollbars(
-            Scrollbars::new(ScrollAxes::Horizontal).ensure_id(core::panic::Location::caller()),
-            window,
-            cx,
-        )
-    }
-
-    #[track_caller]
-    fn vertical_scrollbar(self, window: &mut Window, cx: &mut App) -> Self::Output {
-        self.custom_scrollbars(
-            Scrollbars::new(ScrollAxes::Vertical).ensure_id(core::panic::Location::caller()),
-            window,
-            cx,
-        )
-    }
+    // TODO: account for these cases properly
+    // #[track_caller]
+    // fn horizontal_scrollbar(self, window: &mut Window, cx: &mut App) -> Self::Output {
+    //     self.custom_scrollbars(
+    //         Scrollbars::new(ScrollAxes::Horizontal).ensure_id(core::panic::Location::caller()),
+    //         window,
+    //         cx,
+    //     )
+    // }
+
+    // #[track_caller]
+    // fn vertical_scrollbar(self, window: &mut Window, cx: &mut App) -> Self::Output {
+    //     self.custom_scrollbars(
+    //         Scrollbars::new(ScrollAxes::Vertical).ensure_id(core::panic::Location::caller()),
+    //         window,
+    //         cx,
+    //     )
+    // }
 
     #[track_caller]
     fn vertical_scrollbar_for<ScrollHandle: ScrollableHandle>(
@@ -290,6 +286,30 @@ impl<T: ScrollableHandle> UniformListDecoration for ScrollbarStateWrapper<T> {
 //     }
 // }
 
+#[derive(Copy, Clone, PartialEq, Eq)]
+enum ShowBehavior {
+    Always,
+    Autohide,
+    Never,
+}
+
+impl ShowBehavior {
+    fn from_setting(setting: ShowScrollbar, cx: &mut App) -> Self {
+        match setting {
+            ShowScrollbar::Never => Self::Never,
+            ShowScrollbar::Auto => Self::Autohide,
+            ShowScrollbar::System => {
+                if cx.default_global::<ScrollbarAutoHide>().should_hide() {
+                    Self::Autohide
+                } else {
+                    Self::Always
+                }
+            }
+            ShowScrollbar::Always => Self::Always,
+        }
+    }
+}
+
 pub enum ScrollAxes {
     Horizontal,
     Vertical,
@@ -460,35 +480,103 @@ impl<ScrollHandle: ScrollableHandle> Scrollbars<ScrollHandle> {
     }
 }
 
-#[derive(PartialEq, Eq)]
+#[derive(PartialEq, Clone, Debug)]
 enum VisibilityState {
     Visible,
+    Animating { showing: bool, delta: f32 },
     Hidden,
     Disabled,
 }
 
+const DELTA_MAX: f32 = 1.0;
+
 impl VisibilityState {
-    fn from_show_setting(show_setting: ShowScrollbar) -> Self {
-        if show_setting.show() {
-            Self::Visible
-        } else {
-            Self::Disabled
+    fn from_behavior(behavior: ShowBehavior) -> Self {
+        match behavior {
+            ShowBehavior::Always => Self::Visible,
+            ShowBehavior::Never => Self::Disabled,
+            ShowBehavior::Autohide => Self::for_show(),
+        }
+    }
+
+    fn for_show() -> Self {
+        Self::Animating {
+            showing: true,
+            delta: Default::default(),
+        }
+    }
+
+    fn for_autohide() -> Self {
+        Self::Animating {
+            showing: Default::default(),
+            delta: Default::default(),
         }
     }
 
     fn is_visible(&self) -> bool {
-        *self == VisibilityState::Visible
+        matches!(self, Self::Visible | Self::Animating { .. })
     }
 
     #[inline]
     fn is_disabled(&self) -> bool {
         *self == VisibilityState::Disabled
     }
+
+    fn animation_progress(&self) -> Option<(f32, Duration, bool)> {
+        match self {
+            Self::Animating { showing, delta } => Some((
+                *delta,
+                if *showing {
+                    SCROLLBAR_SHOW_DURATION
+                } else {
+                    SCROLLBAR_HIDE_DURATION
+                },
+                *showing,
+            )),
+            _ => None,
+        }
+    }
+
+    fn set_delta(&mut self, new_delta: f32) {
+        match self {
+            Self::Animating { showing, .. } if new_delta >= DELTA_MAX => {
+                if *showing {
+                    *self = Self::Visible;
+                } else {
+                    *self = Self::Hidden;
+                }
+            }
+            Self::Animating { delta, .. } => *delta = new_delta,
+            _ => {}
+        }
+    }
+
+    fn toggle_visible(&self, show_behavior: ShowBehavior) -> Self {
+        match self {
+            Self::Hidden => {
+                if show_behavior == ShowBehavior::Autohide {
+                    Self::for_show()
+                } else {
+                    Self::Visible
+                }
+            }
+            Self::Animating {
+                showing: false,
+                delta: progress,
+            } => Self::Animating {
+                showing: true,
+                delta: DELTA_MAX - progress,
+            },
+            _ => self.clone(),
+        }
+    }
 }
 
-enum ParentHovered {
-    Yes(bool),
-    No(bool),
+enum ParentHoverEvent {
+    Within,
+    Entered,
+    Exited,
+    Outside,
 }
 
 /// This is used to ensure notifies within the state do not notify the parent
@@ -502,7 +590,7 @@ struct ScrollbarState<T: ScrollableHandle = ScrollHandle> {
     manually_added: bool,
     scroll_handle: T,
     width: ScrollbarWidth,
-    show_setting: ShowScrollbar,
+    show_behavior: ShowBehavior,
     get_visibility: fn(&App) -> ShowScrollbar,
     visibility: Point<ReservedSpace>,
     show_state: VisibilityState,
@@ -526,7 +614,7 @@ impl<T: ScrollableHandle> ScrollbarState<T> {
             Handle::Untracked(func) => (false, func()),
         };
 
-        let show_setting = (config.get_visibility)(cx);
+        let show_behavior = ShowBehavior::from_setting((config.get_visibility)(cx), cx);
         ScrollbarState {
             thumb_state: Default::default(),
             notify_id: config.tracked_entity.map(|id| id.unwrap_or(parent_id)),
@@ -534,9 +622,9 @@ impl<T: ScrollableHandle> ScrollbarState<T> {
             scroll_handle,
             width: config.scrollbar_width,
             visibility: config.visibility,
-            show_setting,
+            show_behavior,
             get_visibility: config.get_visibility,
-            show_state: VisibilityState::from_show_setting(show_setting),
+            show_state: VisibilityState::from_behavior(show_behavior),
             mouse_in_parent: true,
             last_prepaint_state: None,
             _auto_hide_task: None,
@@ -544,22 +632,26 @@ impl<T: ScrollableHandle> ScrollbarState<T> {
     }
 
     fn settings_changed(&mut self, window: &mut Window, cx: &mut Context<Self>) {
-        self.set_show_scrollbar((self.get_visibility)(cx), window, cx);
+        self.set_show_behavior(
+            ShowBehavior::from_setting((self.get_visibility)(cx), cx),
+            window,
+            cx,
+        );
     }
 
     /// Schedules a scrollbar auto hide if no auto hide is currently in progress yet.
     fn schedule_auto_hide(&mut self, window: &mut Window, cx: &mut Context<Self>) {
         if self._auto_hide_task.is_none() {
-            self._auto_hide_task =
-                (self.visible() && self.show_setting.should_auto_hide(cx)).then(|| {
+            self._auto_hide_task = (self.visible() && self.show_behavior == ShowBehavior::Autohide)
+                .then(|| {
                     cx.spawn_in(window, async move |scrollbar_state, cx| {
                         cx.background_executor()
-                            .timer(SCROLLBAR_SHOW_INTERVAL)
+                            .timer(SCROLLBAR_HIDE_DELAY_INTERVAL)
                             .await;
                         scrollbar_state
                             .update(cx, |state, cx| {
                                 if state.thumb_state == ThumbState::Inactive {
-                                    state.set_visibility(VisibilityState::Hidden, cx);
+                                    state.set_visibility(VisibilityState::for_autohide(), cx);
                                 }
                                 state._auto_hide_task.take();
                             })
@@ -570,20 +662,21 @@ impl<T: ScrollableHandle> ScrollbarState<T> {
     }
 
     fn show_scrollbars(&mut self, window: &mut Window, cx: &mut Context<Self>) {
-        self.set_visibility(VisibilityState::Visible, cx);
+        let visibility = self.show_state.toggle_visible(self.show_behavior);
+        self.set_visibility(visibility, cx);
         self._auto_hide_task.take();
         self.schedule_auto_hide(window, cx);
     }
 
-    fn set_show_scrollbar(
+    fn set_show_behavior(
         &mut self,
-        show: ShowScrollbar,
+        behavior: ShowBehavior,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        if self.show_setting != show {
-            self.show_setting = show;
-            self.set_visibility(VisibilityState::from_show_setting(show), cx);
+        if self.show_behavior != behavior {
+            self.show_behavior = behavior;
+            self.set_visibility(VisibilityState::from_behavior(behavior), cx);
             self.schedule_auto_hide(window, cx);
             cx.notify();
         }
@@ -679,7 +772,7 @@ impl<T: ScrollableHandle> ScrollbarState<T> {
             if state == ThumbState::Inactive {
                 self.schedule_auto_hide(window, cx);
             } else {
-                self.set_visibility(VisibilityState::Visible, cx);
+                self.set_visibility(self.show_state.toggle_visible(self.show_behavior), cx);
                 self._auto_hide_task.take();
             }
             self.thumb_state = state;
@@ -687,13 +780,15 @@ impl<T: ScrollableHandle> ScrollbarState<T> {
         }
     }
 
-    fn update_parent_hovered(&mut self, position: &Point<Pixels>) -> ParentHovered {
+    fn update_parent_hovered(&mut self, position: &Point<Pixels>) -> ParentHoverEvent {
         let last_parent_hovered = self.mouse_in_parent;
         self.mouse_in_parent = self.parent_hovered(position);
         let state_changed = self.mouse_in_parent != last_parent_hovered;
-        match self.mouse_in_parent {
-            true => ParentHovered::Yes(state_changed),
-            false => ParentHovered::No(state_changed),
+        match (self.mouse_in_parent, state_changed) {
+            (true, true) => ParentHoverEvent::Entered,
+            (true, false) => ParentHoverEvent::Within,
+            (false, true) => ParentHoverEvent::Exited,
+            (false, false) => ParentHoverEvent::Outside,
         }
     }
 
@@ -963,10 +1058,10 @@ impl PartialEq for ScrollbarPrepaintState {
 
 impl<T: ScrollableHandle> Element for ScrollbarElement<T> {
     type RequestLayoutState = ();
-    type PrepaintState = Option<ScrollbarPrepaintState>;
+    type PrepaintState = Option<(ScrollbarPrepaintState, Option<f32>)>;
 
     fn id(&self) -> Option<ElementId> {
-        None
+        Some(("scrollbar_animation", self.state.entity_id()).into())
     }
 
     fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
@@ -992,7 +1087,7 @@ impl<T: ScrollableHandle> Element for ScrollbarElement<T> {
 
     fn prepaint(
         &mut self,
-        _id: Option<&GlobalElementId>,
+        id: Option<&GlobalElementId>,
         _inspector_id: Option<&gpui::InspectorElementId>,
         bounds: Bounds<Pixels>,
         _request_layout: &mut Self::RequestLayoutState,
@@ -1090,7 +1185,31 @@ impl<T: ScrollableHandle> Element for ScrollbarElement<T> {
                 .update(cx, |state, cx| state.show_scrollbars(window, cx));
         }
 
-        prepaint_state
+        prepaint_state.map(|state| {
+            let autohide_delta = self.state.read(cx).show_state.animation_progress().map(
+                |(delta, delta_duration, should_invert)| {
+                    window.with_element_state(id.unwrap(), |state, window| {
+                        let state = state.unwrap_or_else(|| Instant::now());
+                        let current = Instant::now();
+
+                        let new_delta = DELTA_MAX
+                            .min(delta + (current - state).div_duration_f32(delta_duration));
+                        self.state
+                            .update(cx, |state, _| state.show_state.set_delta(new_delta));
+
+                        window.request_animation_frame();
+                        let delta = if should_invert {
+                            DELTA_MAX - delta
+                        } else {
+                            delta
+                        };
+                        (ease_in_out(delta), current)
+                    })
+                },
+            );
+
+            (state, autohide_delta)
+        })
     }
 
     fn paint(
@@ -1103,7 +1222,7 @@ impl<T: ScrollableHandle> Element for ScrollbarElement<T> {
         window: &mut Window,
         cx: &mut App,
     ) {
-        let Some(prepaint_state) = prepaint_state.take() else {
+        let Some((prepaint_state, autohide_fade)) = prepaint_state.take() else {
             return;
         };
 
@@ -1142,7 +1261,11 @@ impl<T: ScrollableHandle> Element for ScrollbarElement<T> {
                         blend_color.min(blend_color.alpha(MAXIMUM_OPACITY))
                     };
 
-                    let thumb_background = blending_color.blend(thumb_base_color);
+                    let mut thumb_color = blending_color.blend(thumb_base_color);
+
+                    if !hovered && let Some(fade) = autohide_fade {
+                        thumb_color.fade_out(fade);
+                    }
 
                     if let Some((track_bounds, color)) = track_background {
                         window.paint_quad(quad(
@@ -1158,7 +1281,7 @@ impl<T: ScrollableHandle> Element for ScrollbarElement<T> {
                     window.paint_quad(quad(
                         *thumb_bounds,
                         Corners::all(Pixels::MAX).clamp_radii_for_quad_size(thumb_bounds.size),
-                        thumb_background,
+                        thumb_color,
                         Edges::default(),
                         Hsla::transparent_black(),
                         BorderStyle::default(),
@@ -1253,10 +1376,11 @@ impl<T: ScrollableHandle> Element for ScrollbarElement<T> {
                         }
                         _ => state.update(cx, |state, cx| {
                             match state.update_parent_hovered(&event.position) {
-                                ParentHovered::Yes(state_changed)
+                                hover @ ParentHoverEvent::Entered
+                                | hover @ ParentHoverEvent::Within
                                     if event.pressed_button.is_none() =>
                                 {
-                                    if state_changed {
+                                    if matches!(hover, ParentHoverEvent::Entered) {
                                         state.show_scrollbars(window, cx);
                                     }
                                     state.update_hovered_thumb(&event.position, window, cx);
@@ -1264,7 +1388,7 @@ impl<T: ScrollableHandle> Element for ScrollbarElement<T> {
                                         cx.stop_propagation();
                                     }
                                 }
-                                ParentHovered::No(state_changed) if state_changed => {
+                                ParentHoverEvent::Exited => {
                                     state.set_thumb_state(ThumbState::Inactive, window, cx);
                                 }
                                 _ => {}