ui: Implement hover color for scrollbar component (#25525)

Finn Evers created

This PR implements color changing for the scrollbar component based upon
user mouse interaction.


https://github.com/user-attachments/assets/2fd14e2d-cc5c-4272-906e-bd39bfb007e4


This PR also already adds the state for a scrollbar being actively
dragged. However, as themes currently do not provide a color for this
scenario, this implementation re-uses the hover color as a placeholder
instead. If this feature is at all wanted, I can quickly open up a
follow-up PR which adds support for that property to themes as well as
this component.

Release Notes:

- Added hover state to scrollbars outside of the editor.

Change summary

crates/ui/src/components/scrollbar.rs | 77 +++++++++++++++++++---------
1 file changed, 53 insertions(+), 24 deletions(-)

Detailed changes

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

@@ -14,6 +14,14 @@ pub struct Scrollbar {
     kind: ScrollbarAxis,
 }
 
+#[derive(Default, Debug, Clone, Copy)]
+enum ThumbState {
+    #[default]
+    Inactive,
+    Hover,
+    Dragging(Pixels),
+}
+
 impl ScrollableHandle for UniformListScrollHandle {
     fn content_size(&self) -> Size<Pixels> {
         self.0.borrow().base_handle.content_size()
@@ -88,8 +96,7 @@ pub trait ScrollableHandle: Any + Debug {
 /// A scrollbar state that should be persisted across frames.
 #[derive(Clone, Debug)]
 pub struct ScrollbarState {
-    // If Some(), there's an active drag, offset by percentage from the origin of a thumb.
-    drag: Rc<Cell<Option<Pixels>>>,
+    thumb_state: Rc<Cell<ThumbState>>,
     parent_id: Option<EntityId>,
     scroll_handle: Arc<dyn ScrollableHandle>,
 }
@@ -97,7 +104,7 @@ pub struct ScrollbarState {
 impl ScrollbarState {
     pub fn new(scroll: impl ScrollableHandle) -> Self {
         Self {
-            drag: Default::default(),
+            thumb_state: Default::default(),
             parent_id: None,
             scroll_handle: Arc::new(scroll),
         }
@@ -114,7 +121,24 @@ impl ScrollbarState {
     }
 
     pub fn is_dragging(&self) -> bool {
-        self.drag.get().is_some()
+        matches!(self.thumb_state.get(), ThumbState::Dragging(_))
+    }
+
+    fn set_dragging(&self, drag_offset: Pixels) {
+        self.set_thumb_state(ThumbState::Dragging(drag_offset));
+        self.scroll_handle.drag_started();
+    }
+
+    fn set_thumb_hovered(&self, hovered: bool) {
+        self.set_thumb_state(if hovered {
+            ThumbState::Hover
+        } else {
+            ThumbState::Inactive
+        });
+    }
+
+    fn set_thumb_state(&self, state: ThumbState) {
+        self.thumb_state.set(state);
     }
 
     fn thumb_range(&self, axis: ScrollbarAxis) -> Option<Range<f32>> {
@@ -222,9 +246,13 @@ impl Element for Scrollbar {
         window.with_content_mask(Some(ContentMask { bounds }), |window| {
             let axis = self.kind;
             let colors = cx.theme().colors();
-            let thumb_background = colors
-                .surface_background
-                .blend(colors.scrollbar_thumb_background);
+            let thumb_base_color = match self.state.thumb_state.get() {
+                ThumbState::Dragging(_) => colors.scrollbar_thumb_active_background,
+                ThumbState::Hover => colors.scrollbar_thumb_hover_background,
+                ThumbState::Inactive => colors.scrollbar_thumb_background,
+            };
+
+            let thumb_background = colors.surface_background.blend(thumb_base_color);
 
             let padded_bounds = Bounds::from_corners(
                 bounds
@@ -302,11 +330,9 @@ impl Element for Scrollbar {
                         return;
                     }
 
-                    scroll.drag_started();
-
                     if thumb_bounds.contains(&event.position) {
                         let offset = event.position.along(axis) - thumb_bounds.origin.along(axis);
-                        state.drag.set(Some(offset));
+                        state.set_dragging(offset);
                     } else {
                         let click_offset = compute_click_offset(
                             event.position,
@@ -332,26 +358,29 @@ impl Element for Scrollbar {
 
             let state = self.state.clone();
             window.on_mouse_event(move |event: &MouseMoveEvent, _, window, cx| {
-                if let Some(drag_state) = state.drag.get().filter(|_| event.dragging()) {
-                    let drag_offset = compute_click_offset(
-                        event.position,
-                        scroll.content_size(),
-                        ScrollbarMouseEvent::ThumbDrag(drag_state),
-                    );
-                    scroll.set_offset(scroll.offset().apply_along(axis, |_| drag_offset));
-                    window.refresh();
-                    if let Some(id) = state.parent_id {
-                        cx.notify(id);
+                match state.thumb_state.get() {
+                    ThumbState::Dragging(drag_state) if event.dragging() => {
+                        let drag_offset = compute_click_offset(
+                            event.position,
+                            scroll.content_size(),
+                            ScrollbarMouseEvent::ThumbDrag(drag_state),
+                        );
+                        scroll.set_offset(scroll.offset().apply_along(axis, |_| drag_offset));
+                        window.refresh();
+                        if let Some(id) = state.parent_id {
+                            cx.notify(id);
+                        }
                     }
-                } else {
-                    state.drag.set(None);
+                    _ => state.set_thumb_hovered(thumb_bounds.contains(&event.position)),
                 }
             });
             let state = self.state.clone();
             let scroll = self.state.scroll_handle.clone();
-            window.on_mouse_event(move |_event: &MouseUpEvent, phase, _, cx| {
+            window.on_mouse_event(move |event: &MouseUpEvent, phase, _, cx| {
                 if phase.bubble() {
-                    state.drag.take();
+                    if state.is_dragging() {
+                        state.set_thumb_hovered(thumb_bounds.contains(&event.position));
+                    }
                     scroll.drag_ended();
                     if let Some(id) = state.parent_id {
                         cx.notify(id);