editor: Implement hover color for scrollbars (#28064)

Finn Evers created

This PR adds hover colors to the editor scrollbars:


https://github.com/user-attachments/assets/6600810e-7e8e-4dee-9bef-b7be303b5fe0

The color used here is the existing `scrollbar_thumb_hover_background`
color provided by themes.

Looking forward to feedback 😄 

Release Notes:

- Added hover state to editor scrollbars.

Change summary

crates/editor/src/element.rs | 56 ++++++++++++++++++++++------
crates/editor/src/scroll.rs  | 76 ++++++++++++++++++++++++++++++++------
2 files changed, 108 insertions(+), 24 deletions(-)

Detailed changes

crates/editor/src/element.rs 🔗

@@ -25,7 +25,7 @@ use crate::{
     inlay_hint_settings,
     items::BufferSearchHighlights,
     mouse_context_menu::{self, MenuPosition},
-    scroll::scroll_amount::ScrollAmount,
+    scroll::{ActiveScrollbarState, ScrollbarThumbState, scroll_amount::ScrollAmount},
 };
 use buffer_diff::{DiffHunkStatus, DiffHunkStatusKind};
 use collections::{BTreeMap, HashMap};
@@ -1457,7 +1457,7 @@ impl EditorElement {
         // cancel the scrollbar drag.
         if cx.has_active_drag() {
             self.editor.update(cx, |editor, cx| {
-                editor.scroll_manager.reset_scrollbar_dragging_state(cx)
+                editor.scroll_manager.reset_scrollbar_state(cx)
             });
         }
 
@@ -1500,6 +1500,7 @@ impl EditorElement {
             scroll_position,
             self.style.scrollbar_width,
             show_scrollbars,
+            self.editor.read(cx).scroll_manager.active_scrollbar_state(),
             window,
         ))
     }
@@ -5104,7 +5105,7 @@ impl EditorElement {
     }
 
     fn paint_scrollbars(&mut self, layout: &mut EditorLayout, window: &mut Window, cx: &mut App) {
-        let Some(scrollbars_layout) = &layout.scrollbars_layout else {
+        let Some(scrollbars_layout) = layout.scrollbars_layout.take() else {
             return;
         };
 
@@ -5153,10 +5154,16 @@ impl EditorElement {
                         }
                     }
 
+                    let scrollbar_thumb_color = match scrollbar_layout.thumb_state {
+                        ScrollbarThumbState::Dragging | ScrollbarThumbState::Hovered => {
+                            cx.theme().colors().scrollbar_thumb_hover_background
+                        }
+                        ScrollbarThumbState::Idle => cx.theme().colors().scrollbar_thumb_background,
+                    };
                     window.paint_quad(quad(
                         thumb_bounds,
                         Corners::default(),
-                        cx.theme().colors().scrollbar_thumb_background,
+                        scrollbar_thumb_color,
                         scrollbar_edges,
                         cx.theme().colors().scrollbar_thumb_border,
                         BorderStyle::Solid,
@@ -5203,13 +5210,22 @@ impl EditorElement {
                             });
                             editor.set_scroll_position(position, window, cx);
                         }
+
+                        editor.scroll_manager.show_scrollbars(window, cx);
                         cx.stop_propagation();
-                    } else {
-                        editor.scroll_manager.reset_scrollbar_dragging_state(cx);
-                    }
+                    } else if let Some((layout, axis)) = scrollbars_layout.get_hovered_axis(window)
+                    {
+                        if layout.thumb_bounds().contains(&event.position) {
+                            editor
+                                .scroll_manager
+                                .set_hovered_scroll_thumb_axis(axis, cx);
+                        } else {
+                            editor.scroll_manager.reset_scrollbar_state(cx);
+                        }
 
-                    if scrollbars_layout.get_hovered_axis(window).is_some() {
                         editor.scroll_manager.show_scrollbars(window, cx);
+                    } else {
+                        editor.scroll_manager.reset_scrollbar_state(cx);
                     }
 
                     mouse_position = event.position;
@@ -5220,13 +5236,19 @@ impl EditorElement {
         if self.editor.read(cx).scroll_manager.any_scrollbar_dragged() {
             window.on_mouse_event({
                 let editor = self.editor.clone();
-                move |_: &MouseUpEvent, phase, _, cx| {
+                move |_: &MouseUpEvent, phase, window, cx| {
                     if phase == DispatchPhase::Capture {
                         return;
                     }
 
                     editor.update(cx, |editor, cx| {
-                        editor.scroll_manager.reset_scrollbar_dragging_state(cx);
+                        if let Some((_, axis)) = scrollbars_layout.get_hovered_axis(window) {
+                            editor
+                                .scroll_manager
+                                .set_hovered_scroll_thumb_axis(axis, cx);
+                        } else {
+                            editor.scroll_manager.reset_scrollbar_state(cx);
+                        }
                         cx.stop_propagation();
                     });
                 }
@@ -5234,7 +5256,6 @@ impl EditorElement {
         } else {
             window.on_mouse_event({
                 let editor = self.editor.clone();
-                let scrollbars_layout = scrollbars_layout.clone();
 
                 move |event: &MouseDownEvent, phase, window, cx| {
                     if phase == DispatchPhase::Capture {
@@ -5255,7 +5276,9 @@ impl EditorElement {
                     let thumb_bounds = scrollbar_layout.thumb_bounds();
 
                     editor.update(cx, |editor, cx| {
-                        editor.scroll_manager.set_dragged_scrollbar_axis(axis, cx);
+                        editor
+                            .scroll_manager
+                            .set_dragged_scroll_thumb_axis(axis, cx);
 
                         let event_position = event.position.along(axis);
 
@@ -8037,6 +8060,7 @@ impl EditorScrollbars {
         scroll_position: gpui::Point<f32>,
         scrollbar_width: Pixels,
         show_scrollbars: bool,
+        scrollbar_state: Option<&ActiveScrollbarState>,
         window: &mut Window,
     ) -> Self {
         let ScrollbarLayoutInformation {
@@ -8082,6 +8106,10 @@ impl EditorScrollbars {
                     axis != ScrollbarAxis::Horizontal || editor_content_size < scroll_range
                 })
                 .map(|(editor_content_size, scroll_range)| {
+                    let thumb_state = scrollbar_state
+                        .and_then(|state| state.thumb_state_for_axis(axis))
+                        .unwrap_or(ScrollbarThumbState::Idle);
+
                     ScrollbarLayout::new(
                         window.insert_hitbox(scrollbar_bounds_for(axis), false),
                         editor_content_size,
@@ -8089,6 +8117,7 @@ impl EditorScrollbars {
                         glyph_grid_cell.along(axis),
                         content_offset.along(axis),
                         scroll_position.along(axis),
+                        thumb_state,
                         axis,
                     )
                 })
@@ -8124,6 +8153,7 @@ struct ScrollbarLayout {
     text_unit_size: Pixels,
     content_offset: Pixels,
     thumb_size: Pixels,
+    thumb_state: ScrollbarThumbState,
     axis: ScrollbarAxis,
 }
 
@@ -8140,6 +8170,7 @@ impl ScrollbarLayout {
         glyph_space: Pixels,
         content_offset: Pixels,
         scroll_position: f32,
+        thumb_state: ScrollbarThumbState,
         axis: ScrollbarAxis,
     ) -> Self {
         let track_bounds = scrollbar_track_hitbox.bounds;
@@ -8164,6 +8195,7 @@ impl ScrollbarLayout {
             text_unit_size,
             content_offset,
             thumb_size,
+            thumb_state,
             axis,
         }
     }

crates/editor/src/scroll.rs 🔗

@@ -123,6 +123,29 @@ impl OngoingScroll {
     }
 }
 
+#[derive(Copy, Clone, PartialEq, Eq)]
+pub enum ScrollbarThumbState {
+    Idle,
+    Hovered,
+    Dragging,
+}
+
+#[derive(PartialEq, Eq)]
+pub struct ActiveScrollbarState {
+    axis: Axis,
+    thumb_state: ScrollbarThumbState,
+}
+
+impl ActiveScrollbarState {
+    pub fn new(axis: Axis, thumb_state: ScrollbarThumbState) -> Self {
+        ActiveScrollbarState { axis, thumb_state }
+    }
+
+    pub fn thumb_state_for_axis(&self, axis: Axis) -> Option<ScrollbarThumbState> {
+        (self.axis == axis).then_some(self.thumb_state)
+    }
+}
+
 pub struct ScrollManager {
     pub(crate) vertical_scroll_margin: f32,
     anchor: ScrollAnchor,
@@ -131,7 +154,7 @@ pub struct ScrollManager {
     last_autoscroll: Option<(gpui::Point<f32>, f32, f32, AutoscrollStrategy)>,
     show_scrollbars: bool,
     hide_scrollbar_task: Option<Task<()>>,
-    dragging_scrollbar: Option<Axis>,
+    active_scrollbar: Option<ActiveScrollbarState>,
     visible_line_count: Option<f32>,
     forbid_vertical_scroll: bool,
 }
@@ -145,7 +168,7 @@ impl ScrollManager {
             autoscroll_request: None,
             show_scrollbars: true,
             hide_scrollbar_task: None,
-            dragging_scrollbar: None,
+            active_scrollbar: None,
             last_autoscroll: None,
             visible_line_count: None,
             forbid_vertical_scroll: false,
@@ -322,24 +345,53 @@ impl ScrollManager {
         self.autoscroll_request.map(|(autoscroll, _)| autoscroll)
     }
 
+    pub fn active_scrollbar_state(&self) -> Option<&ActiveScrollbarState> {
+        self.active_scrollbar.as_ref()
+    }
+
     pub fn dragging_scrollbar_axis(&self) -> Option<Axis> {
-        self.dragging_scrollbar
+        self.active_scrollbar
+            .as_ref()
+            .map(|scrollbar| scrollbar.axis)
     }
 
     pub fn any_scrollbar_dragged(&self) -> bool {
-        self.dragging_scrollbar.is_some()
+        self.active_scrollbar
+            .as_ref()
+            .is_some_and(|scrollbar| scrollbar.thumb_state == ScrollbarThumbState::Dragging)
     }
 
-    pub fn set_dragged_scrollbar_axis(&mut self, axis: Axis, cx: &mut Context<Editor>) {
-        if self.dragging_scrollbar != Some(axis) {
-            self.dragging_scrollbar = Some(axis);
-            cx.notify();
-        }
+    pub fn set_hovered_scroll_thumb_axis(&mut self, axis: Axis, cx: &mut Context<Editor>) {
+        self.update_active_scrollbar_state(
+            Some(ActiveScrollbarState::new(
+                axis,
+                ScrollbarThumbState::Hovered,
+            )),
+            cx,
+        );
+    }
+
+    pub fn set_dragged_scroll_thumb_axis(&mut self, axis: Axis, cx: &mut Context<Editor>) {
+        self.update_active_scrollbar_state(
+            Some(ActiveScrollbarState::new(
+                axis,
+                ScrollbarThumbState::Dragging,
+            )),
+            cx,
+        );
+    }
+
+    pub fn reset_scrollbar_state(&mut self, cx: &mut Context<Editor>) {
+        self.update_active_scrollbar_state(None, cx);
     }
 
-    pub fn reset_scrollbar_dragging_state(&mut self, cx: &mut Context<Editor>) {
-        if self.dragging_scrollbar.is_some() {
-            self.dragging_scrollbar = None;
+    fn update_active_scrollbar_state(
+        &mut self,
+        new_state: Option<ActiveScrollbarState>,
+        cx: &mut Context<Editor>,
+    ) {
+        if self.active_scrollbar != new_state {
+            self.active_scrollbar = new_state;
             cx.notify();
         }
     }