Refactor the scrollbar component (#36105)

Finn Evers created

Closes https://github.com/zed-industries/zed/issues/37621
Improves https://github.com/zed-industries/zed/issues/24623

Adding scrollbars withing Zed's UI currently is rather cumbersome, as it
requires the copying of a lot of code in order for these to work. Wiring
up settings for scrollbar visibilty always has to be done at the call
site and the state has to be saved and maintained by the caller as well.
Similarly, reserving space has to also be handled by the caller.

This PR changes the way scrollbars work in Zed fundamentally by making
use of the new `use_keyed_state` APIs: Instead of saving the state at
the call site, the window now keeps track of the state corresponding to
scrollbars. This enables us to add scrollbars with e.g. one simple call
on divs:
```rust
div()
    .vertical_scrollbar(window, cx)
```
will add a scrollbar to the corresponding container. There are some more
improvements regarding tracking of scrollbar visibility settings (which
is now handled by a trait for each setting that supports this) as well
as reserving space.
Additionally, all needed stuff for layouting, catching events and
reserving space is also now managed by the scrollbar component instead.
This drastically reduces the amount of event listeners and makes
layouting of two scrollbars easier.

Furthermore, this paves the way for more improvements to scrollbars,
such as graceful auto-hide. Only downsight here is that we lose some
customizability in a few areas. However, once this lands, we gain the
ability to quickly follow these up without breaking stuff elsewhere.

This also already fixes a few bugs:
- Scrollbars no longer flicker on first render. 
- Auto-hide now properly works for all scrollbars.
- If the content size changes, the scrollbar is updated on the same
frame. Both of these happened because we were computing the scrollbar
sizes too early, causing us to use the sizes from the previous frame or
unitialized sizes.
- The project panel no longer jumps if scrolled all the way to the
bottom and the scrollbar actually auto-hides.

Still TODO:
- [x] Fix scrolling in the debugger memory view
- [x] Clean up some more in the scrollbar component and reduce clones
there
- [x] Ensure we don't over-notify the entity the scrollbar is rendered
within
- [x] Make sure auto-hide properly works for all cases
- [x] Check whether we want to implement the scrollbar trait for
`UniformList`s as well
    - ~~ [ ] Use for uniformlist where possible~~ Postponed
- [x] Improve layout for cases where we render both scrollbars.

Release Notes:

- N/A

Change summary

Cargo.lock                                                 |   2 
crates/agent_ui/src/acp/thread_history.rs                  |  54 
crates/agent_ui/src/acp/thread_view.rs                     |  74 
crates/agent_ui/src/agent_configuration.rs                 |  57 
crates/debugger_ui/src/session/running/breakpoint_list.rs  |  44 
crates/debugger_ui/src/session/running/memory_view.rs      | 212 
crates/debugger_ui/src/session/running/module_list.rs      |  42 
crates/debugger_ui/src/session/running/stack_frame_list.rs |  42 
crates/debugger_ui/src/session/running/variable_list.rs    |  46 
crates/editor/src/editor.rs                                |   6 
crates/editor/src/editor_settings.rs                       |  24 
crates/editor/src/element.rs                               |   4 
crates/editor/src/hover_popover.rs                         |  92 
crates/editor/src/scroll.rs                                |  10 
crates/editor/src/signature_help.rs                        |  35 
crates/extensions_ui/src/extensions_ui.rs                  |  43 
crates/git_ui/src/git_panel.rs                             | 391 ---
crates/git_ui/src/git_panel_settings.rs                    |  19 
crates/gpui/src/elements/div.rs                            |  17 
crates/gpui/src/elements/uniform_list.rs                   |  35 
crates/gpui/src/style.rs                                   |   4 
crates/gpui/src/taffy.rs                                   |  11 
crates/gpui/src/window.rs                                  |   6 
crates/keymap_editor/src/keymap_editor.rs                  |  25 
crates/keymap_editor/src/ui_components/table.rs            | 471 ---
crates/outline_panel/src/outline_panel.rs                  | 201 -
crates/outline_panel/src/outline_panel_settings.rs         |  13 
crates/picker/Cargo.toml                                   |   1 
crates/picker/src/picker.rs                                | 112 
crates/project_panel/src/project_panel.rs                  | 181 -
crates/project_panel/src/project_panel_settings.rs         |  11 
crates/recent_projects/src/remote_servers.rs               |  28 
crates/terminal_view/src/terminal_view.rs                  | 216 -
crates/ui/Cargo.toml                                       |   1 
crates/ui/src/components/scrollbar.rs                      | 996 ++++++-
35 files changed, 1,311 insertions(+), 2,215 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -12214,7 +12214,6 @@ dependencies = [
  "serde",
  "serde_json",
  "ui",
- "util",
  "workspace",
  "workspace-hack",
 ]
@@ -17632,6 +17631,7 @@ dependencies = [
  "icons",
  "itertools 0.14.0",
  "menu",
+ "schemars",
  "serde",
  "settings",
  "smallvec",

crates/agent_ui/src/acp/thread_history.rs 🔗

@@ -5,15 +5,15 @@ use chrono::{Datelike as _, Local, NaiveDate, TimeDelta};
 use editor::{Editor, EditorEvent};
 use fuzzy::StringMatchCandidate;
 use gpui::{
-    App, Entity, EventEmitter, FocusHandle, Focusable, ScrollStrategy, Stateful, Task,
+    App, Entity, EventEmitter, FocusHandle, Focusable, ScrollStrategy, Task,
     UniformListScrollHandle, WeakEntity, Window, uniform_list,
 };
 use std::{fmt::Display, ops::Range};
 use text::Bias;
 use time::{OffsetDateTime, UtcOffset};
 use ui::{
-    HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, Scrollbar, ScrollbarState,
-    Tooltip, prelude::*,
+    HighlightedLabel, IconButtonShape, ListItem, ListItemSpacing, Tooltip, WithScrollbar,
+    prelude::*,
 };
 
 pub struct AcpThreadHistory {
@@ -26,8 +26,6 @@ pub struct AcpThreadHistory {
 
     visible_items: Vec<ListItemType>,
 
-    scrollbar_visibility: bool,
-    scrollbar_state: ScrollbarState,
     local_timezone: UtcOffset,
 
     _update_task: Task<()>,
@@ -90,7 +88,6 @@ impl AcpThreadHistory {
         });
 
         let scroll_handle = UniformListScrollHandle::default();
-        let scrollbar_state = ScrollbarState::new(scroll_handle.clone());
 
         let mut this = Self {
             history_store,
@@ -99,8 +96,6 @@ impl AcpThreadHistory {
             hovered_index: None,
             visible_items: Default::default(),
             search_editor,
-            scrollbar_visibility: true,
-            scrollbar_state,
             local_timezone: UtcOffset::from_whole_seconds(
                 chrono::Local::now().offset().local_minus_utc(),
             )
@@ -339,43 +334,6 @@ impl AcpThreadHistory {
         task.detach_and_log_err(cx);
     }
 
-    fn render_scrollbar(&self, cx: &mut Context<Self>) -> Option<Stateful<Div>> {
-        if !(self.scrollbar_visibility || self.scrollbar_state.is_dragging()) {
-            return None;
-        }
-
-        Some(
-            div()
-                .occlude()
-                .id("thread-history-scroll")
-                .h_full()
-                .bg(cx.theme().colors().panel_background.opacity(0.8))
-                .border_l_1()
-                .border_color(cx.theme().colors().border_variant)
-                .absolute()
-                .right_1()
-                .top_0()
-                .bottom_0()
-                .w_4()
-                .pl_1()
-                .cursor_default()
-                .on_mouse_move(cx.listener(|_, _, _window, cx| {
-                    cx.notify();
-                    cx.stop_propagation()
-                }))
-                .on_hover(|_, _window, cx| {
-                    cx.stop_propagation();
-                })
-                .on_any_mouse_down(|_, _window, cx| {
-                    cx.stop_propagation();
-                })
-                .on_scroll_wheel(cx.listener(|_, _, _window, cx| {
-                    cx.notify();
-                }))
-                .children(Scrollbar::vertical(self.scrollbar_state.clone())),
-        )
-    }
-
     fn render_list_items(
         &mut self,
         range: Range<usize>,
@@ -491,7 +449,7 @@ impl Focusable for AcpThreadHistory {
 }
 
 impl Render for AcpThreadHistory {
-    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
         v_flex()
             .key_context("ThreadHistory")
             .size_full()
@@ -555,9 +513,7 @@ impl Render for AcpThreadHistory {
                             .track_scroll(self.scroll_handle.clone())
                             .flex_grow(),
                         )
-                        .when_some(self.render_scrollbar(cx), |div, scrollbar| {
-                            div.child(scrollbar)
-                        })
+                        .vertical_scrollbar_for(self.scroll_handle.clone(), window, cx)
                 }
             })
     }

crates/agent_ui/src/acp/thread_view.rs 🔗

@@ -24,10 +24,9 @@ use futures::FutureExt as _;
 use gpui::{
     Action, Animation, AnimationExt, AnyView, App, BorderStyle, ClickEvent, ClipboardItem,
     CursorStyle, EdgesRefinement, ElementId, Empty, Entity, FocusHandle, Focusable, Hsla, Length,
-    ListOffset, ListState, MouseButton, PlatformDisplay, SharedString, Stateful, StyleRefinement,
-    Subscription, Task, TextStyle, TextStyleRefinement, UnderlineStyle, WeakEntity, Window,
-    WindowHandle, div, ease_in_out, linear_color_stop, linear_gradient, list, point, prelude::*,
-    pulsating_between,
+    ListOffset, ListState, PlatformDisplay, SharedString, StyleRefinement, Subscription, Task,
+    TextStyle, TextStyleRefinement, UnderlineStyle, WeakEntity, Window, WindowHandle, div,
+    ease_in_out, linear_color_stop, linear_gradient, list, point, prelude::*, pulsating_between,
 };
 use language::Buffer;
 
@@ -47,7 +46,7 @@ use text::Anchor;
 use theme::{AgentFontSize, ThemeSettings};
 use ui::{
     Callout, CommonAnimationExt, Disclosure, Divider, DividerColor, ElevationIndex, KeyBinding,
-    PopoverMenuHandle, Scrollbar, ScrollbarState, SpinnerLabel, TintColor, Tooltip, prelude::*,
+    PopoverMenuHandle, SpinnerLabel, TintColor, Tooltip, WithScrollbar, prelude::*,
 };
 use util::{ResultExt, size::format_file_size, time::duration_alt_display};
 use workspace::{CollaboratorId, Workspace};
@@ -281,7 +280,6 @@ pub struct AcpThreadView {
     thread_error: Option<ThreadError>,
     thread_feedback: ThreadFeedbackState,
     list_state: ListState,
-    scrollbar_state: ScrollbarState,
     auth_task: Option<Task<()>>,
     expanded_tool_calls: HashSet<acp::ToolCallId>,
     expanded_thinking_blocks: HashSet<(usize, usize)>,
@@ -405,8 +403,7 @@ impl AcpThreadView {
 
             notifications: Vec::new(),
             notification_subscriptions: HashMap::default(),
-            list_state: list_state.clone(),
-            scrollbar_state: ScrollbarState::new(list_state).parent_entity(&cx.entity()),
+            list_state: list_state,
             thread_retry_status: None,
             thread_error: None,
             thread_feedback: Default::default(),
@@ -4764,39 +4761,6 @@ impl AcpThreadView {
         cx.notify();
     }
 
-    fn render_vertical_scrollbar(&self, cx: &mut Context<Self>) -> Stateful<Div> {
-        div()
-            .id("acp-thread-scrollbar")
-            .occlude()
-            .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_mouse_up(
-                MouseButton::Left,
-                cx.listener(|_, _, _, cx| {
-                    cx.stop_propagation();
-                }),
-            )
-            .on_scroll_wheel(cx.listener(|_, _, _, cx| {
-                cx.notify();
-            }))
-            .h_full()
-            .absolute()
-            .right_1()
-            .top_1()
-            .bottom_0()
-            .w(px(12.))
-            .cursor_default()
-            .children(Scrollbar::vertical(self.scrollbar_state.clone()).map(|s| s.auto_hide(cx)))
-    }
-
     fn render_token_limit_callout(
         &self,
         line_height: Pixels,
@@ -5370,23 +5334,27 @@ impl Render for AcpThreadView {
                     configuration_view,
                     pending_auth_method,
                     ..
-                } => self.render_auth_required_state(
-                    connection,
-                    description.as_ref(),
-                    configuration_view.as_ref(),
-                    pending_auth_method.as_ref(),
-                    window,
-                    cx,
-                ),
+                } => self
+                    .render_auth_required_state(
+                        connection,
+                        description.as_ref(),
+                        configuration_view.as_ref(),
+                        pending_auth_method.as_ref(),
+                        window,
+                        cx,
+                    )
+                    .into_any(),
                 ThreadState::Loading { .. } => v_flex()
                     .flex_1()
-                    .child(self.render_recent_history(window, cx)),
+                    .child(self.render_recent_history(window, cx))
+                    .into_any(),
                 ThreadState::LoadError(e) => v_flex()
                     .flex_1()
                     .size_full()
                     .items_center()
                     .justify_end()
-                    .child(self.render_load_error(e, window, cx)),
+                    .child(self.render_load_error(e, window, cx))
+                    .into_any(),
                 ThreadState::Ready { .. } => v_flex().flex_1().map(|this| {
                     if has_messages {
                         this.child(
@@ -5406,9 +5374,11 @@ impl Render for AcpThreadView {
                             .flex_grow()
                             .into_any(),
                         )
-                        .child(self.render_vertical_scrollbar(cx))
+                        .vertical_scrollbar_for(self.list_state.clone(), window, cx)
+                        .into_any()
                     } else {
                         this.child(self.render_recent_history(window, cx))
+                            .into_any()
                     }
                 }),
             })

crates/agent_ui/src/agent_configuration.rs 🔗

@@ -35,8 +35,7 @@ use project::{
 use settings::{Settings, SettingsStore, update_settings_file};
 use ui::{
     Chip, CommonAnimationExt, ContextMenu, Disclosure, Divider, DividerColor, ElevationIndex,
-    Indicator, PopoverMenu, Scrollbar, ScrollbarState, Switch, SwitchColor, SwitchField, Tooltip,
-    prelude::*,
+    Indicator, PopoverMenu, Switch, SwitchColor, SwitchField, Tooltip, WithScrollbar, prelude::*,
 };
 use util::ResultExt as _;
 use workspace::{Workspace, create_and_open_local_file};
@@ -64,7 +63,6 @@ pub struct AgentConfiguration {
     tools: Entity<ToolWorkingSet>,
     _registry_subscription: Subscription,
     scroll_handle: ScrollHandle,
-    scrollbar_state: ScrollbarState,
     _check_for_gemini: Task<()>,
 }
 
@@ -101,9 +99,6 @@ impl AgentConfiguration {
         cx.subscribe(&context_server_store, |_, _, _, cx| cx.notify())
             .detach();
 
-        let scroll_handle = ScrollHandle::new();
-        let scrollbar_state = ScrollbarState::new(scroll_handle.clone());
-
         let mut this = Self {
             fs,
             language_registry,
@@ -116,8 +111,7 @@ impl AgentConfiguration {
             expanded_provider_configurations: HashMap::default(),
             tools,
             _registry_subscription: registry_subscription,
-            scroll_handle,
-            scrollbar_state,
+            scroll_handle: ScrollHandle::new(),
             _check_for_gemini: Task::ready(()),
         };
         this.build_provider_configuration_views(window, cx);
@@ -1157,42 +1151,21 @@ impl Render for AgentConfiguration {
             .size_full()
             .pb_8()
             .bg(cx.theme().colors().panel_background)
-            .child(
-                v_flex()
-                    .id("assistant-configuration-content")
-                    .track_scroll(&self.scroll_handle)
-                    .size_full()
-                    .overflow_y_scroll()
-                    .child(self.render_general_settings_section(cx))
-                    .child(self.render_agent_servers_section(cx))
-                    .child(self.render_context_servers_section(window, cx))
-                    .child(self.render_provider_configuration_section(cx)),
-            )
             .child(
                 div()
-                    .id("assistant-configuration-scrollbar")
-                    .occlude()
-                    .absolute()
-                    .right(px(3.))
-                    .top_0()
-                    .bottom_0()
-                    .pb_6()
-                    .w(px(12.))
-                    .cursor_default()
-                    .on_mouse_move(cx.listener(|_, _, _window, cx| {
-                        cx.notify();
-                        cx.stop_propagation()
-                    }))
-                    .on_hover(|_, _window, cx| {
-                        cx.stop_propagation();
-                    })
-                    .on_any_mouse_down(|_, _window, cx| {
-                        cx.stop_propagation();
-                    })
-                    .on_scroll_wheel(cx.listener(|_, _, _window, cx| {
-                        cx.notify();
-                    }))
-                    .children(Scrollbar::vertical(self.scrollbar_state.clone())),
+                    .size_full()
+                    .child(
+                        v_flex()
+                            .id("assistant-configuration-content")
+                            .track_scroll(&self.scroll_handle)
+                            .size_full()
+                            .overflow_y_scroll()
+                            .child(self.render_general_settings_section(cx))
+                            .child(self.render_agent_servers_section(cx))
+                            .child(self.render_context_servers_section(window, cx))
+                            .child(self.render_provider_configuration_section(cx)),
+                    )
+                    .vertical_scrollbar_for(self.scroll_handle.clone(), window, cx),
             )
     }
 }

crates/debugger_ui/src/session/running/breakpoint_list.rs 🔗

@@ -10,7 +10,7 @@ use db::kvp::KEY_VALUE_STORE;
 use editor::Editor;
 use gpui::{
     Action, AppContext, ClickEvent, Entity, FocusHandle, Focusable, MouseButton, ScrollStrategy,
-    Stateful, Task, UniformListScrollHandle, WeakEntity, actions, uniform_list,
+    Task, UniformListScrollHandle, WeakEntity, actions, uniform_list,
 };
 use language::Point;
 use project::{
@@ -23,8 +23,8 @@ use project::{
     worktree_store::WorktreeStore,
 };
 use ui::{
-    Divider, DividerColor, FluentBuilder as _, Indicator, IntoElement, ListItem, Render, Scrollbar,
-    ScrollbarState, StatefulInteractiveElement, Tooltip, prelude::*,
+    Divider, DividerColor, FluentBuilder as _, Indicator, IntoElement, ListItem, Render,
+    StatefulInteractiveElement, Tooltip, WithScrollbar, prelude::*,
 };
 use workspace::Workspace;
 use zed_actions::{ToggleEnableBreakpoint, UnsetBreakpoint};
@@ -49,7 +49,6 @@ pub(crate) struct BreakpointList {
     breakpoint_store: Entity<BreakpointStore>,
     dap_store: Entity<DapStore>,
     worktree_store: Entity<WorktreeStore>,
-    scrollbar_state: ScrollbarState,
     breakpoints: Vec<BreakpointEntry>,
     session: Option<Entity<Session>>,
     focus_handle: FocusHandle,
@@ -87,7 +86,6 @@ impl BreakpointList {
         let dap_store = project.dap_store();
         let focus_handle = cx.focus_handle();
         let scroll_handle = UniformListScrollHandle::new();
-        let scrollbar_state = ScrollbarState::new(scroll_handle.clone());
 
         let adapter_name = session.as_ref().map(|session| session.read(cx).adapter());
         cx.new(|cx| {
@@ -95,7 +93,6 @@ impl BreakpointList {
                 breakpoint_store,
                 dap_store,
                 worktree_store,
-                scrollbar_state,
                 breakpoints: Default::default(),
                 workspace,
                 session,
@@ -576,39 +573,6 @@ impl BreakpointList {
         .flex_1()
     }
 
-    fn render_vertical_scrollbar(&self, cx: &mut Context<Self>) -> Stateful<Div> {
-        div()
-            .occlude()
-            .id("breakpoint-list-vertical-scrollbar")
-            .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_mouse_up(
-                MouseButton::Left,
-                cx.listener(|_, _, _, cx| {
-                    cx.stop_propagation();
-                }),
-            )
-            .on_scroll_wheel(cx.listener(|_, _, _, cx| {
-                cx.notify();
-            }))
-            .h_full()
-            .absolute()
-            .right_1()
-            .top_1()
-            .bottom_0()
-            .w(px(12.))
-            .cursor_default()
-            .children(Scrollbar::vertical(self.scrollbar_state.clone()).map(|s| s.auto_hide(cx)))
-    }
-
     pub(crate) fn render_control_strip(&self) -> AnyElement {
         let selection_kind = self.selection_kind();
         let focus_handle = self.focus_handle.clone();
@@ -789,7 +753,7 @@ impl Render for BreakpointList {
             .size_full()
             .pt_1()
             .child(self.render_list(cx))
-            .child(self.render_vertical_scrollbar(cx))
+            .vertical_scrollbar_for(self.scroll_handle.clone(), window, cx)
             .when_some(self.strip_mode, |this, _| {
                 this.child(Divider::horizontal().color(DividerColor::Border))
                     .child(

crates/debugger_ui/src/session/running/memory_view.rs 🔗

@@ -1,17 +1,17 @@
 use std::{
-    cell::LazyCell,
+    cell::{LazyCell, RefCell, RefMut},
     fmt::Write,
     ops::RangeInclusive,
+    rc::Rc,
     sync::{Arc, LazyLock},
     time::Duration,
 };
 
 use editor::{Editor, EditorElement, EditorStyle};
 use gpui::{
-    Action, AppContext, DismissEvent, DragMoveEvent, Empty, Entity, FocusHandle, Focusable,
-    MouseButton, Point, ScrollStrategy, ScrollWheelEvent, Stateful, Subscription, Task, TextStyle,
-    UniformList, UniformListScrollHandle, WeakEntity, actions, anchored, deferred, point,
-    uniform_list,
+    Action, Along, AppContext, Axis, DismissEvent, DragMoveEvent, Empty, Entity, FocusHandle,
+    Focusable, MouseButton, Point, ScrollStrategy, ScrollWheelEvent, Subscription, Task, TextStyle,
+    UniformList, UniformListScrollHandle, WeakEntity, actions, anchored, deferred, uniform_list,
 };
 use notifications::status_toast::{StatusToast, ToastIcon};
 use project::debugger::{MemoryCell, dap_command::DataBreakpointContext, session::Session};
@@ -19,7 +19,7 @@ use settings::Settings;
 use theme::ThemeSettings;
 use ui::{
     ContextMenu, Divider, DropdownMenu, FluentBuilder, IntoElement, PopoverMenuHandle, Render,
-    Scrollbar, ScrollbarState, StatefulInteractiveElement, Tooltip, prelude::*,
+    ScrollableHandle, StatefulInteractiveElement, Tooltip, WithScrollbar, prelude::*,
 };
 use workspace::Workspace;
 
@@ -29,11 +29,9 @@ actions!(debugger, [GoToSelectedAddress]);
 
 pub(crate) struct MemoryView {
     workspace: WeakEntity<Workspace>,
-    scroll_handle: UniformListScrollHandle,
-    scroll_state: ScrollbarState,
     stack_frame_list: WeakEntity<StackFrameList>,
     focus_handle: FocusHandle,
-    view_state: ViewState,
+    view_state_handle: ViewStateHandle,
     query_editor: Entity<Editor>,
     session: Entity<Session>,
     width_picker_handle: PopoverMenuHandle<ContextMenu>,
@@ -90,18 +88,29 @@ impl SelectedMemoryRange {
     }
 }
 
+#[derive(Clone)]
+struct ViewStateHandle(Rc<RefCell<ViewState>>);
+
+impl ViewStateHandle {
+    fn new(base_row: u64, line_width: ViewWidth) -> Self {
+        Self(Rc::new(RefCell::new(ViewState::new(base_row, line_width))))
+    }
+}
+
 #[derive(Clone)]
 struct ViewState {
     /// Uppermost row index
     base_row: u64,
     /// How many cells per row do we have?
     line_width: ViewWidth,
+    scroll_handle: UniformListScrollHandle,
     selection: Option<SelectedMemoryRange>,
 }
 
 impl ViewState {
     fn new(base_row: u64, line_width: ViewWidth) -> Self {
         Self {
+            scroll_handle: UniformListScrollHandle::new(),
             base_row,
             line_width,
             selection: None,
@@ -119,13 +128,39 @@ impl ViewState {
     fn schedule_scroll_up(&mut self) {
         self.base_row = self.base_row.saturating_sub(1);
     }
+
+    fn set_offset(&mut self, point: Point<Pixels>) {
+        if point.y >= -Pixels::ZERO {
+            self.schedule_scroll_up();
+        } else if point.y <= -self.scroll_handle.max_offset().height {
+            self.schedule_scroll_down();
+        }
+        self.scroll_handle.set_offset(point);
+    }
 }
 
-struct ScrollbarDragging;
+impl ScrollableHandle for ViewStateHandle {
+    fn max_offset(&self) -> gpui::Size<Pixels> {
+        self.0.borrow().scroll_handle.max_offset()
+    }
+
+    fn set_offset(&self, point: Point<Pixels>) {
+        self.0.borrow_mut().set_offset(point);
+    }
+
+    fn offset(&self) -> Point<Pixels> {
+        self.0.borrow().scroll_handle.offset()
+    }
+
+    fn viewport(&self) -> gpui::Bounds<Pixels> {
+        self.0.borrow().scroll_handle.viewport()
+    }
+}
 
 static HEX_BYTES_MEMOIZED: LazyLock<[SharedString; 256]> =
     LazyLock::new(|| std::array::from_fn(|byte| SharedString::from(format!("{byte:02X}"))));
 static UNKNOWN_BYTE: SharedString = SharedString::new_static("??");
+
 impl MemoryView {
     pub(crate) fn new(
         session: Entity<Session>,
@@ -134,19 +169,15 @@ impl MemoryView {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Self {
-        let view_state = ViewState::new(0, WIDTHS[4].clone());
-        let scroll_handle = UniformListScrollHandle::default();
+        let view_state_handle = ViewStateHandle::new(0, WIDTHS[4].clone());
 
         let query_editor = cx.new(|cx| Editor::single_line(window, cx));
 
-        let scroll_state = ScrollbarState::new(scroll_handle.clone());
         let mut this = Self {
             workspace,
-            scroll_state,
-            scroll_handle,
             stack_frame_list,
             focus_handle: cx.focus_handle(),
-            view_state,
+            view_state_handle,
             query_editor,
             session,
             width_picker_handle: Default::default(),
@@ -162,50 +193,17 @@ impl MemoryView {
         this
     }
 
-    fn render_vertical_scrollbar(&self, cx: &mut Context<Self>) -> Stateful<Div> {
-        div()
-            .occlude()
-            .id("memory-view-vertical-scrollbar")
-            .on_drag_move(cx.listener(|this, evt, _, cx| {
-                let did_handle = this.handle_scroll_drag(evt);
-                cx.notify();
-                if did_handle {
-                    cx.stop_propagation()
-                }
-            }))
-            .on_drag(ScrollbarDragging, |_, _, _, cx| cx.new(|_| Empty))
-            .on_hover(|_, _, cx| {
-                cx.stop_propagation();
-            })
-            .on_any_mouse_down(|_, _, cx| {
-                cx.stop_propagation();
-            })
-            .on_mouse_up(
-                MouseButton::Left,
-                cx.listener(|_, _, _, cx| {
-                    cx.stop_propagation();
-                }),
-            )
-            .on_scroll_wheel(cx.listener(|_, _, _, cx| {
-                cx.notify();
-            }))
-            .h_full()
-            .absolute()
-            .right_1()
-            .top_1()
-            .bottom_0()
-            .w(px(12.))
-            .cursor_default()
-            .children(Scrollbar::vertical(self.scroll_state.clone()).map(|s| s.auto_hide(cx)))
+    fn view_state(&self) -> RefMut<'_, ViewState> {
+        self.view_state_handle.0.borrow_mut()
     }
 
     fn render_memory(&self, cx: &mut Context<Self>) -> UniformList {
         let weak = cx.weak_entity();
         let session = self.session.clone();
-        let view_state = self.view_state.clone();
+        let view_state = self.view_state_handle.0.borrow().clone();
         uniform_list(
             "debugger-memory-view",
-            self.view_state.row_count() as usize,
+            view_state.row_count() as usize,
             move |range, _, cx| {
                 let mut line_buffer = Vec::with_capacity(view_state.line_width.width as usize);
                 let memory_start =
@@ -230,22 +228,13 @@ impl MemoryView {
                 rows
             },
         )
-        .track_scroll(self.scroll_handle.clone())
+        .track_scroll(view_state.scroll_handle)
         .on_scroll_wheel(cx.listener(|this, evt: &ScrollWheelEvent, window, _| {
+            let mut view_state = this.view_state();
             let delta = evt.delta.pixel_delta(window.line_height());
-            let scroll_handle = this.scroll_state.scroll_handle();
-            let size = scroll_handle.content_size();
-            let viewport = scroll_handle.viewport();
-            let current_offset = scroll_handle.offset();
-            let first_entry_offset_boundary = size.height / this.view_state.row_count() as f32;
-            let last_entry_offset_boundary = size.height - first_entry_offset_boundary;
-            if first_entry_offset_boundary + viewport.size.height > current_offset.y.abs() {
-                // The topmost entry is visible, hence if we're scrolling up, we need to load extra lines.
-                this.view_state.schedule_scroll_up();
-            } else if last_entry_offset_boundary < current_offset.y.abs() + viewport.size.height {
-                this.view_state.schedule_scroll_down();
-            }
-            scroll_handle.set_offset(current_offset + point(px(0.), delta.y));
+            let current_offset = view_state.scroll_handle.offset();
+            view_state
+                .set_offset(current_offset.apply_along(Axis::Vertical, |offset| offset + delta.y));
         }))
     }
     fn render_query_bar(&self, cx: &Context<Self>) -> impl IntoElement {
@@ -275,7 +264,7 @@ impl MemoryView {
         cx.spawn(async move |this, cx| {
             let access_size = access_size.await.unwrap_or(1);
             this.update(cx, |this, cx| {
-                this.view_state.selection = Some(SelectedMemoryRange::DragComplete(Drag {
+                this.view_state().selection = Some(SelectedMemoryRange::DragComplete(Drag {
                     start_address: as_address,
                     end_address: as_address + access_size - 1,
                 }));
@@ -287,43 +276,23 @@ impl MemoryView {
     }
 
     fn handle_memory_drag(&mut self, evt: &DragMoveEvent<Drag>) {
-        if !self
-            .view_state
+        let mut view_state = self.view_state();
+        if !view_state
             .selection
             .as_ref()
             .is_some_and(|selection| selection.is_dragging())
         {
             return;
         }
-        let row_count = self.view_state.row_count();
-        debug_assert!(row_count > 1);
-        let scroll_handle = self.scroll_state.scroll_handle();
-        let viewport = scroll_handle.viewport();
-
-        if viewport.bottom() < evt.event.position.y {
-            self.view_state.schedule_scroll_down();
-        } else if viewport.top() > evt.event.position.y {
-            self.view_state.schedule_scroll_up();
-        }
-    }
-
-    fn handle_scroll_drag(&mut self, evt: &DragMoveEvent<ScrollbarDragging>) -> bool {
-        if !self.scroll_state.is_dragging() {
-            return false;
-        }
-        let row_count = self.view_state.row_count();
+        let row_count = view_state.row_count();
         debug_assert!(row_count > 1);
-        let scroll_handle = self.scroll_state.scroll_handle();
+        let scroll_handle = &view_state.scroll_handle;
         let viewport = scroll_handle.viewport();
 
         if viewport.bottom() < evt.event.position.y {
-            self.view_state.schedule_scroll_down();
-            true
+            view_state.schedule_scroll_down();
         } else if viewport.top() > evt.event.position.y {
-            self.view_state.schedule_scroll_up();
-            true
-        } else {
-            false
+            view_state.schedule_scroll_up();
         }
     }
 
@@ -354,7 +323,7 @@ impl MemoryView {
 
     fn render_width_picker(&self, window: &mut Window, cx: &mut Context<Self>) -> DropdownMenu {
         let weak = cx.weak_entity();
-        let selected_width = self.view_state.line_width.clone();
+        let selected_width = self.view_state().line_width.clone();
         DropdownMenu::new(
             "memory-view-width-picker",
             selected_width.label.clone(),
@@ -364,24 +333,25 @@ impl MemoryView {
                     let width = width.clone();
                     this = this.entry(width.label.clone(), None, move |_, cx| {
                         _ = weak.update(cx, |this, _| {
+                            let mut view_state = this.view_state();
                             // Convert base ix between 2 line widths to keep the shown memory address roughly the same.
                             // All widths are powers of 2, so the conversion should be lossless.
-                            match this.view_state.line_width.width.cmp(&width.width) {
+                            match view_state.line_width.width.cmp(&width.width) {
                                 std::cmp::Ordering::Less => {
                                     // We're converting up.
                                     let shift = width.width.trailing_zeros()
-                                        - this.view_state.line_width.width.trailing_zeros();
-                                    this.view_state.base_row >>= shift;
+                                        - view_state.line_width.width.trailing_zeros();
+                                    view_state.base_row >>= shift;
                                 }
                                 std::cmp::Ordering::Greater => {
                                     // We're converting down.
-                                    let shift = this.view_state.line_width.width.trailing_zeros()
+                                    let shift = view_state.line_width.width.trailing_zeros()
                                         - width.width.trailing_zeros();
-                                    this.view_state.base_row <<= shift;
+                                    view_state.base_row <<= shift;
                                 }
                                 _ => {}
                             }
-                            this.view_state.line_width = width.clone();
+                            view_state.line_width = width.clone();
                         });
                     });
                 }
@@ -400,18 +370,18 @@ impl MemoryView {
     }
 
     fn page_down(&mut self, _: &menu::SelectLast, _: &mut Window, cx: &mut Context<Self>) {
-        self.view_state.base_row = self
-            .view_state
+        let mut view_state = self.view_state();
+        view_state.base_row = view_state
             .base_row
-            .overflowing_add(self.view_state.row_count())
+            .overflowing_add(view_state.row_count())
             .0;
         cx.notify();
     }
     fn page_up(&mut self, _: &menu::SelectFirst, _: &mut Window, cx: &mut Context<Self>) {
-        self.view_state.base_row = self
-            .view_state
+        let mut view_state = self.view_state();
+        view_state.base_row = view_state
             .base_row
-            .overflowing_sub(self.view_state.row_count())
+            .overflowing_sub(view_state.row_count())
             .0;
         cx.notify();
     }
@@ -447,7 +417,8 @@ impl MemoryView {
         _: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        let Some(SelectedMemoryRange::DragComplete(selection)) = self.view_state.selection.clone()
+        let Some(SelectedMemoryRange::DragComplete(selection)) =
+            self.view_state().selection.clone()
         else {
             return;
         };
@@ -484,7 +455,8 @@ impl MemoryView {
     }
 
     fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
-        if let Some(SelectedMemoryRange::DragComplete(drag)) = &self.view_state.selection {
+        let selection = self.view_state().selection.clone();
+        if let Some(SelectedMemoryRange::DragComplete(drag)) = selection {
             // Go into memory writing mode.
             if !self.is_writing_memory {
                 let should_return = self.session.update(cx, |session, cx| {
@@ -558,9 +530,11 @@ impl MemoryView {
     }
 
     fn jump_to_address(&mut self, address: u64, cx: &mut Context<Self>) {
-        self.view_state.base_row = (address & !0xfff) / self.view_state.line_width.width as u64;
-        let line_ix = (address & 0xfff) / self.view_state.line_width.width as u64;
-        self.scroll_handle
+        let mut view_state = self.view_state();
+        view_state.base_row = (address & !0xfff) / view_state.line_width.width as u64;
+        let line_ix = (address & 0xfff) / view_state.line_width.width as u64;
+        view_state
+            .scroll_handle
             .scroll_to_item(line_ix as usize, ScrollStrategy::Center);
         cx.notify();
     }
@@ -595,7 +569,7 @@ impl MemoryView {
     }
 
     fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
-        self.view_state.selection = None;
+        self.view_state().selection = None;
         cx.notify();
     }
 
@@ -606,7 +580,7 @@ impl MemoryView {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        let Some(SelectedMemoryRange::DragComplete(drag)) = self.view_state.selection.clone()
+        let Some(SelectedMemoryRange::DragComplete(drag)) = self.view_state().selection.clone()
         else {
             return;
         };
@@ -718,7 +692,7 @@ fn render_single_memory_view_line(
     weak: gpui::WeakEntity<MemoryView>,
     cx: &mut App,
 ) -> AnyElement {
-    let Ok(view_state) = weak.update(cx, |this, _| this.view_state.clone()) else {
+    let Ok(view_state) = weak.update(cx, |this, _| this.view_state().clone()) else {
         return div().into_any();
     };
     let base_address = (view_state.base_row + ix) * view_state.line_width.width as u64;
@@ -799,7 +773,7 @@ fn render_single_memory_view_line(
                                 let weak = weak.clone();
                                 move |drag, _, _, cx| {
                                     _ = weak.update(cx, |this, _| {
-                                        this.view_state.selection =
+                                        this.view_state().selection =
                                             Some(SelectedMemoryRange::DragUnderway(drag.clone()));
                                     });
 
@@ -811,7 +785,7 @@ fn render_single_memory_view_line(
                             let weak = weak.clone();
                             move |drag: &Drag, _, cx| {
                                 _ = weak.update(cx, |this, _| {
-                                    this.view_state.selection =
+                                    this.view_state().selection =
                                         Some(SelectedMemoryRange::DragComplete(Drag {
                                             start_address: drag.start_address,
                                             end_address: base_address + cell_ix as u64,
@@ -821,7 +795,7 @@ fn render_single_memory_view_line(
                         })
                         .drag_over(move |style, drag: &Drag, _, cx| {
                             _ = weak.update(cx, |this, _| {
-                                this.view_state.selection =
+                                this.view_state().selection =
                                     Some(SelectedMemoryRange::DragUnderway(Drag {
                                         start_address: drag.start_address,
                                         end_address: base_address + cell_ix as u64,
@@ -943,7 +917,7 @@ impl Render for MemoryView {
                         )
                         .with_priority(1)
                     }))
-                    .child(self.render_vertical_scrollbar(cx)),
+                    .vertical_scrollbar_for(self.view_state_handle.clone(), window, cx),
             )
     }
 }

crates/debugger_ui/src/session/running/module_list.rs 🔗

@@ -1,15 +1,15 @@
 use anyhow::anyhow;
 use dap::Module;
 use gpui::{
-    AnyElement, Entity, FocusHandle, Focusable, MouseButton, ScrollStrategy, Stateful,
-    Subscription, Task, UniformListScrollHandle, WeakEntity, uniform_list,
+    AnyElement, Entity, FocusHandle, Focusable, ScrollStrategy, Subscription, Task,
+    UniformListScrollHandle, WeakEntity, uniform_list,
 };
 use project::{
     ProjectItem as _, ProjectPath,
     debugger::session::{Session, SessionEvent},
 };
 use std::{ops::Range, path::Path, sync::Arc};
-use ui::{Scrollbar, ScrollbarState, prelude::*};
+use ui::{WithScrollbar, prelude::*};
 use workspace::Workspace;
 
 pub struct ModuleList {
@@ -18,7 +18,6 @@ pub struct ModuleList {
     session: Entity<Session>,
     workspace: WeakEntity<Workspace>,
     focus_handle: FocusHandle,
-    scrollbar_state: ScrollbarState,
     entries: Vec<Module>,
     _rebuild_task: Option<Task<()>>,
     _subscription: Subscription,
@@ -44,7 +43,6 @@ impl ModuleList {
         let scroll_handle = UniformListScrollHandle::new();
 
         Self {
-            scrollbar_state: ScrollbarState::new(scroll_handle.clone()),
             scroll_handle,
             session,
             workspace,
@@ -167,38 +165,6 @@ impl ModuleList {
         self.session
             .update(cx, |session, cx| session.modules(cx).to_vec())
     }
-    fn render_vertical_scrollbar(&self, cx: &mut Context<Self>) -> Stateful<Div> {
-        div()
-            .occlude()
-            .id("module-list-vertical-scrollbar")
-            .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_mouse_up(
-                MouseButton::Left,
-                cx.listener(|_, _, _, cx| {
-                    cx.stop_propagation();
-                }),
-            )
-            .on_scroll_wheel(cx.listener(|_, _, _, cx| {
-                cx.notify();
-            }))
-            .h_full()
-            .absolute()
-            .right_1()
-            .top_1()
-            .bottom_0()
-            .w(px(12.))
-            .cursor_default()
-            .children(Scrollbar::vertical(self.scrollbar_state.clone()))
-    }
 
     fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
         let Some(ix) = self.selected_ix else { return };
@@ -313,6 +279,6 @@ impl Render for ModuleList {
             .size_full()
             .p_1()
             .child(self.render_list(window, cx))
-            .child(self.render_vertical_scrollbar(cx))
+            .vertical_scrollbar_for(self.scroll_handle.clone(), window, cx)
     }
 }

crates/debugger_ui/src/session/running/stack_frame_list.rs 🔗

@@ -7,7 +7,7 @@ use dap::StackFrameId;
 use db::kvp::KEY_VALUE_STORE;
 use gpui::{
     Action, AnyElement, Entity, EventEmitter, FocusHandle, Focusable, FontWeight, ListState,
-    MouseButton, Stateful, Subscription, Task, WeakEntity, list,
+    Subscription, Task, WeakEntity, list,
 };
 use util::debug_panic;
 
@@ -16,7 +16,7 @@ use language::PointUtf16;
 use project::debugger::breakpoint_store::ActiveStackFrame;
 use project::debugger::session::{Session, SessionEvent, StackFrame, ThreadStatus};
 use project::{ProjectItem, ProjectPath};
-use ui::{Scrollbar, ScrollbarState, Tooltip, prelude::*};
+use ui::{Tooltip, WithScrollbar, prelude::*};
 use workspace::{ItemHandle, Workspace};
 
 use super::RunningState;
@@ -64,7 +64,6 @@ pub struct StackFrameList {
     workspace: WeakEntity<Workspace>,
     selected_ix: Option<usize>,
     opened_stack_frame_id: Option<StackFrameId>,
-    scrollbar_state: ScrollbarState,
     list_state: ListState,
     list_filter: StackFrameFilter,
     filter_entries_indices: Vec<usize>,
@@ -102,7 +101,6 @@ impl StackFrameList {
             });
 
         let list_state = ListState::new(0, gpui::ListAlignment::Top, px(1000.));
-        let scrollbar_state = ScrollbarState::new(list_state.clone());
 
         let list_filter = KEY_VALUE_STORE
             .read_kvp(&format!(
@@ -127,7 +125,6 @@ impl StackFrameList {
             opened_stack_frame_id: None,
             list_filter,
             list_state,
-            scrollbar_state,
             _refresh_task: Task::ready(()),
         };
         this.schedule_refresh(true, window, cx);
@@ -697,39 +694,6 @@ impl StackFrameList {
         }
     }
 
-    fn render_vertical_scrollbar(&self, cx: &mut Context<Self>) -> Stateful<Div> {
-        div()
-            .occlude()
-            .id("stack-frame-list-vertical-scrollbar")
-            .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_mouse_up(
-                MouseButton::Left,
-                cx.listener(|_, _, _, cx| {
-                    cx.stop_propagation();
-                }),
-            )
-            .on_scroll_wheel(cx.listener(|_, _, _, cx| {
-                cx.notify();
-            }))
-            .h_full()
-            .absolute()
-            .right_1()
-            .top_1()
-            .bottom_0()
-            .w(px(12.))
-            .cursor_default()
-            .children(Scrollbar::vertical(self.scrollbar_state.clone()))
-    }
-
     fn select_ix(&mut self, ix: Option<usize>, cx: &mut Context<Self>) {
         self.selected_ix = ix;
         cx.notify();
@@ -941,7 +905,7 @@ impl Render for StackFrameList {
                 )
             })
             .child(self.render_list(window, cx))
-            .child(self.render_vertical_scrollbar(cx))
+            .vertical_scrollbar_for(self.list_state.clone(), window, cx)
     }
 }
 

crates/debugger_ui/src/session/running/variable_list.rs 🔗

@@ -8,9 +8,8 @@ use dap::{
 use editor::Editor;
 use gpui::{
     Action, AnyElement, ClickEvent, ClipboardItem, Context, DismissEvent, Empty, Entity,
-    FocusHandle, Focusable, Hsla, MouseButton, MouseDownEvent, Point, Stateful, Subscription,
-    TextStyleRefinement, UniformListScrollHandle, WeakEntity, actions, anchored, deferred,
-    uniform_list,
+    FocusHandle, Focusable, Hsla, MouseDownEvent, Point, Subscription, TextStyleRefinement,
+    UniformListScrollHandle, WeakEntity, actions, anchored, deferred, uniform_list,
 };
 use menu::{SelectFirst, SelectLast, SelectNext, SelectPrevious};
 use project::debugger::{
@@ -18,7 +17,7 @@ use project::debugger::{
     session::{Session, SessionEvent, Watcher},
 };
 use std::{collections::HashMap, ops::Range, sync::Arc};
-use ui::{ContextMenu, ListItem, ScrollableHandle, Scrollbar, ScrollbarState, Tooltip, prelude::*};
+use ui::{ContextMenu, ListItem, ScrollableHandle, Tooltip, WithScrollbar, prelude::*};
 use util::{debug_panic, maybe};
 
 actions!(
@@ -189,7 +188,6 @@ pub struct VariableList {
     entry_states: HashMap<EntryPath, EntryState>,
     selected_stack_frame_id: Option<StackFrameId>,
     list_handle: UniformListScrollHandle,
-    scrollbar_state: ScrollbarState,
     session: Entity<Session>,
     selection: Option<EntryPath>,
     open_context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
@@ -235,7 +233,6 @@ impl VariableList {
         let list_state = UniformListScrollHandle::default();
 
         Self {
-            scrollbar_state: ScrollbarState::new(list_state.clone()),
             list_handle: list_state,
             session,
             focus_handle,
@@ -1500,39 +1497,6 @@ impl VariableList {
             )
             .into_any()
     }
-
-    fn render_vertical_scrollbar(&self, cx: &mut Context<Self>) -> Stateful<Div> {
-        div()
-            .occlude()
-            .id("variable-list-vertical-scrollbar")
-            .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_mouse_up(
-                MouseButton::Left,
-                cx.listener(|_, _, _, cx| {
-                    cx.stop_propagation();
-                }),
-            )
-            .on_scroll_wheel(cx.listener(|_, _, _, cx| {
-                cx.notify();
-            }))
-            .h_full()
-            .absolute()
-            .right_1()
-            .top_1()
-            .bottom_0()
-            .w(px(12.))
-            .cursor_default()
-            .children(Scrollbar::vertical(self.scrollbar_state.clone()))
-    }
 }
 
 impl Focusable for VariableList {
@@ -1542,7 +1506,7 @@ impl Focusable for VariableList {
 }
 
 impl Render for VariableList {
-    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
         v_flex()
             .track_focus(&self.focus_handle)
             .key_context("VariableList")
@@ -1587,7 +1551,7 @@ impl Render for VariableList {
                 )
                 .with_priority(1)
             }))
-            .child(self.render_vertical_scrollbar(cx))
+            .vertical_scrollbar_for(self.list_handle.clone(), window, cx)
     }
 }
 

crates/editor/src/editor.rs 🔗

@@ -55,7 +55,7 @@ pub use display_map::{ChunkRenderer, ChunkRendererContext, DisplayPoint, FoldPla
 pub use edit_prediction::Direction;
 pub use editor_settings::{
     CurrentLineHighlight, DocumentColorsRenderMode, EditorSettings, HideMouseMode,
-    ScrollBeyondLastLine, ScrollbarAxes, SearchSettings, ShowMinimap, ShowScrollbar,
+    ScrollBeyondLastLine, ScrollbarAxes, SearchSettings, ShowMinimap,
 };
 pub use editor_settings_controls::*;
 pub use element::{
@@ -166,7 +166,7 @@ use project::{
 };
 use rand::seq::SliceRandom;
 use rpc::{ErrorCode, ErrorExt, proto::PeerId};
-use scroll::{Autoscroll, OngoingScroll, ScrollAnchor, ScrollManager, ScrollbarAutoHide};
+use scroll::{Autoscroll, OngoingScroll, ScrollAnchor, ScrollManager};
 use selections_collection::{
     MutableSelectionsCollection, SelectionsCollection, resolve_selections,
 };
@@ -196,7 +196,7 @@ use theme::{
 };
 use ui::{
     ButtonSize, ButtonStyle, ContextMenu, Disclosure, IconButton, IconButtonShape, IconName,
-    IconSize, Indicator, Key, Tooltip, h_flex, prelude::*,
+    IconSize, Indicator, Key, Tooltip, h_flex, prelude::*, scrollbars::ScrollbarAutoHide,
 };
 use util::{RangeExt, ResultExt, TryFutureExt, maybe, post_inc};
 use workspace::{

crates/editor/src/editor_settings.rs 🔗

@@ -7,6 +7,7 @@ use project::project_settings::DiagnosticSeverity;
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use settings::{Settings, SettingsKey, SettingsSources, SettingsUi, VsCodeSettings};
+use ui::scrollbars::{ScrollbarVisibility, ShowScrollbar};
 use util::serde::default_true;
 
 /// Imports from the VSCode settings at
@@ -205,23 +206,6 @@ pub struct Gutter {
     pub folds: bool,
 }
 
-/// When to show the scrollbar in the editor.
-///
-/// Default: auto
-#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
-#[serde(rename_all = "snake_case")]
-pub enum ShowScrollbar {
-    /// Show the scrollbar if there's important information or
-    /// follow the system's configured behavior.
-    Auto,
-    /// Match the system's configured behavior.
-    System,
-    /// Always show the scrollbar.
-    Always,
-    /// Never show the scrollbar.
-    Never,
-}
-
 /// When to show the minimap in the editor.
 ///
 /// Default: never
@@ -778,6 +762,12 @@ impl EditorSettings {
     }
 }
 
+impl ScrollbarVisibility for EditorSettings {
+    fn visibility(&self, _cx: &App) -> ShowScrollbar {
+        self.scrollbar.show
+    }
+}
+
 impl Settings for EditorSettings {
     type FileContent = EditorSettingsContent;
 

crates/editor/src/element.rs 🔗

@@ -18,7 +18,7 @@ use crate::{
     editor_settings::{
         CurrentLineHighlight, DocumentColorsRenderMode, DoubleClickInMultibuffer, Minimap,
         MinimapThumb, MinimapThumbBorder, ScrollBeyondLastLine, ScrollbarAxes,
-        ScrollbarDiagnostics, ShowMinimap, ShowScrollbar,
+        ScrollbarDiagnostics, ShowMinimap,
     },
     git::blame::{BlameRenderer, GitBlame, GlobalBlameRenderer},
     hover_popover::{
@@ -85,7 +85,7 @@ use theme::{ActiveTheme, Appearance, BufferLineHeight, PlayerColor};
 use ui::utils::ensure_minimum_contrast;
 use ui::{
     ButtonLike, ContextMenu, Indicator, KeyBinding, POPOVER_Y_PADDING, Tooltip, h_flex, prelude::*,
-    right_click_menu,
+    right_click_menu, scrollbars::ShowScrollbar,
 };
 use unicode_segmentation::UnicodeSegmentation;
 use util::post_inc;

crates/editor/src/hover_popover.rs 🔗

@@ -9,8 +9,8 @@ use anyhow::Context as _;
 use gpui::{
     AnyElement, AsyncWindowContext, Context, Entity, Focusable as _, FontWeight, Hsla,
     InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, ScrollHandle, Size,
-    Stateful, StatefulInteractiveElement, StyleRefinement, Styled, Subscription, Task,
-    TextStyleRefinement, Window, div, px,
+    StatefulInteractiveElement, StyleRefinement, Styled, Subscription, Task, TextStyleRefinement,
+    Window, div, px,
 };
 use itertools::Itertools;
 use language::{DiagnosticEntry, Language, LanguageRegistry};
@@ -23,7 +23,7 @@ use std::{borrow::Cow, cell::RefCell};
 use std::{ops::Range, sync::Arc, time::Duration};
 use std::{path::PathBuf, rc::Rc};
 use theme::ThemeSettings;
-use ui::{Scrollbar, ScrollbarState, prelude::*, theme_is_transparent};
+use ui::{Scrollbars, WithScrollbar, prelude::*, theme_is_transparent};
 use url::Url;
 use util::TryFutureExt;
 use workspace::{OpenOptions, OpenVisible, Workspace};
@@ -184,7 +184,6 @@ pub fn hover_at_inlay(
                 let hover_popover = InfoPopover {
                     symbol_range: RangeInEditor::Inlay(inlay_hover.range.clone()),
                     parsed_content,
-                    scrollbar_state: ScrollbarState::new(scroll_handle.clone()),
                     scroll_handle,
                     keyboard_grace: Rc::new(RefCell::new(false)),
                     anchor: None,
@@ -387,7 +386,6 @@ fn show_hover(
                     local_diagnostic,
                     markdown,
                     border_color,
-                    scrollbar_state: ScrollbarState::new(scroll_handle.clone()),
                     scroll_handle,
                     background_color,
                     keyboard_grace: Rc::new(RefCell::new(ignore_timeout)),
@@ -457,7 +455,6 @@ fn show_hover(
                 info_popovers.push(InfoPopover {
                     symbol_range: RangeInEditor::Text(range),
                     parsed_content,
-                    scrollbar_state: ScrollbarState::new(scroll_handle.clone()),
                     scroll_handle,
                     keyboard_grace: Rc::new(RefCell::new(ignore_timeout)),
                     anchor: Some(anchor),
@@ -507,7 +504,6 @@ fn show_hover(
                 info_popovers.push(InfoPopover {
                     symbol_range: RangeInEditor::Text(range),
                     parsed_content,
-                    scrollbar_state: ScrollbarState::new(scroll_handle.clone()),
                     scroll_handle,
                     keyboard_grace: Rc::new(RefCell::new(ignore_timeout)),
                     anchor: Some(anchor),
@@ -846,7 +842,6 @@ pub struct InfoPopover {
     pub symbol_range: RangeInEditor,
     pub parsed_content: Option<Entity<Markdown>>,
     pub scroll_handle: ScrollHandle,
-    pub scrollbar_state: ScrollbarState,
     pub keyboard_grace: Rc<RefCell<bool>>,
     pub anchor: Option<Anchor>,
     _subscription: Option<Subscription>,
@@ -891,7 +886,12 @@ impl InfoPopover {
                                 .on_url_click(open_markdown_url),
                         ),
                 )
-                .child(self.render_vertical_scrollbar(cx))
+                .custom_scrollbars(
+                    Scrollbars::for_settings::<EditorSettings>()
+                        .tracked_scroll_handle(self.scroll_handle.clone()),
+                    window,
+                    cx,
+                )
             })
             .into_any_element()
     }
@@ -905,39 +905,6 @@ impl InfoPopover {
         cx.notify();
         self.scroll_handle.set_offset(current);
     }
-
-    fn render_vertical_scrollbar(&self, cx: &mut Context<Editor>) -> Stateful<Div> {
-        div()
-            .occlude()
-            .id("info-popover-vertical-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_mouse_up(
-                MouseButton::Left,
-                cx.listener(|_, _, _, cx| {
-                    cx.stop_propagation();
-                }),
-            )
-            .on_scroll_wheel(cx.listener(|_, _, _, cx| {
-                cx.notify();
-            }))
-            .h_full()
-            .absolute()
-            .right_1()
-            .top_1()
-            .bottom_0()
-            .w(px(12.))
-            .cursor_default()
-            .children(Scrollbar::vertical(self.scrollbar_state.clone()))
-    }
 }
 
 pub struct DiagnosticPopover {
@@ -949,7 +916,6 @@ pub struct DiagnosticPopover {
     pub anchor: Anchor,
     _subscription: Subscription,
     pub scroll_handle: ScrollHandle,
-    pub scrollbar_state: ScrollbarState,
 }
 
 impl DiagnosticPopover {
@@ -1013,43 +979,15 @@ impl DiagnosticPopover {
                                 ),
                             ),
                     )
-                    .child(self.render_vertical_scrollbar(cx)),
+                    .custom_scrollbars(
+                        Scrollbars::for_settings::<EditorSettings>()
+                            .tracked_scroll_handle(self.scroll_handle.clone()),
+                        window,
+                        cx,
+                    ),
             )
             .into_any_element()
     }
-
-    fn render_vertical_scrollbar(&self, cx: &mut Context<Editor>) -> Stateful<Div> {
-        div()
-            .occlude()
-            .id("diagnostic-popover-vertical-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_mouse_up(
-                MouseButton::Left,
-                cx.listener(|_, _, _, cx| {
-                    cx.stop_propagation();
-                }),
-            )
-            .on_scroll_wheel(cx.listener(|_, _, _, cx| {
-                cx.notify();
-            }))
-            .h_full()
-            .absolute()
-            .right_1()
-            .top_1()
-            .bottom_0()
-            .w(px(12.))
-            .cursor_default()
-            .children(Scrollbar::vertical(self.scrollbar_state.clone()))
-    }
 }
 
 #[cfg(test)]

crates/editor/src/scroll.rs 🔗

@@ -12,7 +12,7 @@ use crate::{
 };
 pub use autoscroll::{Autoscroll, AutoscrollStrategy};
 use core::fmt::Debug;
-use gpui::{Along, App, Axis, Context, Global, Pixels, Task, Window, point, px};
+use gpui::{Along, App, Axis, Context, Pixels, Task, Window, point, px};
 use language::language_settings::{AllLanguageSettings, SoftWrap};
 use language::{Bias, Point};
 pub use scroll_amount::ScrollAmount;
@@ -21,6 +21,7 @@ use std::{
     cmp::Ordering,
     time::{Duration, Instant},
 };
+use ui::scrollbars::ScrollbarAutoHide;
 use util::ResultExt;
 use workspace::{ItemId, WorkspaceId};
 
@@ -29,11 +30,6 @@ const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
 
 pub struct WasScrolled(pub(crate) bool);
 
-#[derive(Default)]
-pub struct ScrollbarAutoHide(pub bool);
-
-impl Global for ScrollbarAutoHide {}
-
 #[derive(Clone, Copy, Debug, PartialEq)]
 pub struct ScrollAnchor {
     pub offset: gpui::Point<f32>,
@@ -327,7 +323,7 @@ impl ScrollManager {
             cx.notify();
         }
 
-        if cx.default_global::<ScrollbarAutoHide>().0 {
+        if cx.default_global::<ScrollbarAutoHide>().should_hide() {
             self.hide_scrollbar_task = Some(cx.spawn_in(window, async move |editor, cx| {
                 cx.background_executor()
                     .timer(SCROLLBAR_SHOW_INTERVAL)

crates/editor/src/signature_help.rs 🔗

@@ -2,8 +2,8 @@ use crate::actions::ShowSignatureHelp;
 use crate::hover_popover::open_markdown_url;
 use crate::{Editor, EditorSettings, ToggleAutoSignatureHelp, hover_markdown_style};
 use gpui::{
-    App, Context, Div, Entity, HighlightStyle, MouseButton, ScrollHandle, Size, Stateful,
-    StyledText, Task, TextStyle, Window, combine_highlights,
+    App, Context, Entity, HighlightStyle, MouseButton, ScrollHandle, Size, StyledText, Task,
+    TextStyle, Window, combine_highlights,
 };
 use language::BufferSnapshot;
 use markdown::{Markdown, MarkdownElement};
@@ -15,8 +15,8 @@ use theme::ThemeSettings;
 use ui::{
     ActiveTheme, AnyElement, ButtonCommon, ButtonStyle, Clickable, FluentBuilder, IconButton,
     IconButtonShape, IconName, IconSize, InteractiveElement, IntoElement, Label, LabelCommon,
-    LabelSize, ParentElement, Pixels, Scrollbar, ScrollbarState, SharedString,
-    StatefulInteractiveElement, Styled, StyledExt, div, px, relative,
+    LabelSize, ParentElement, Pixels, SharedString, StatefulInteractiveElement, Styled, StyledExt,
+    WithScrollbar, div, relative,
 };
 
 // Language-specific settings may define quotes as "brackets", so filter them out separately.
@@ -243,7 +243,6 @@ impl Editor {
                             .min(signatures.len().saturating_sub(1));
 
                         let signature_help_popover = SignatureHelpPopover {
-                            scrollbar_state: ScrollbarState::new(scroll_handle.clone()),
                             style,
                             signatures,
                             current_signature,
@@ -330,7 +329,6 @@ pub struct SignatureHelpPopover {
     pub signatures: Vec<SignatureHelp>,
     pub current_signature: usize,
     scroll_handle: ScrollHandle,
-    scrollbar_state: ScrollbarState,
 }
 
 impl SignatureHelpPopover {
@@ -391,7 +389,8 @@ impl SignatureHelpPopover {
                             )
                     }),
             )
-            .child(self.render_vertical_scrollbar(cx));
+            .vertical_scrollbar(window, cx);
+
         let controls = if self.signatures.len() > 1 {
             let prev_button = IconButton::new("signature_help_prev", IconName::ChevronUp)
                 .shape(IconButtonShape::Square)
@@ -460,26 +459,4 @@ impl SignatureHelpPopover {
             .child(main_content)
             .into_any_element()
     }
-
-    fn render_vertical_scrollbar(&self, cx: &mut Context<Editor>) -> Stateful<Div> {
-        div()
-            .occlude()
-            .id("signature_help_scrollbar")
-            .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_mouse_up(MouseButton::Left, |_, _, cx| cx.stop_propagation())
-            .on_scroll_wheel(cx.listener(|_, _, _, cx| cx.notify()))
-            .h_full()
-            .absolute()
-            .right_1()
-            .top_1()
-            .bottom_1()
-            .w(px(12.))
-            .cursor_default()
-            .children(Scrollbar::vertical(self.scrollbar_state.clone()))
-    }
 }

crates/extensions_ui/src/extensions_ui.rs 🔗

@@ -24,8 +24,8 @@ use settings::Settings;
 use strum::IntoEnumIterator as _;
 use theme::ThemeSettings;
 use ui::{
-    CheckboxWithLabel, Chip, ContextMenu, PopoverMenu, ScrollableHandle, Scrollbar, ScrollbarState,
-    ToggleButton, Tooltip, prelude::*,
+    CheckboxWithLabel, Chip, ContextMenu, PopoverMenu, ScrollableHandle, ToggleButton, Tooltip,
+    WithScrollbar, prelude::*,
 };
 use vim_mode_setting::VimModeSetting;
 use workspace::{
@@ -290,7 +290,6 @@ pub struct ExtensionsPage {
     _subscriptions: [gpui::Subscription; 2],
     extension_fetch_task: Option<Task<()>>,
     upsells: BTreeSet<Feature>,
-    scrollbar_state: ScrollbarState,
 }
 
 impl ExtensionsPage {
@@ -339,7 +338,7 @@ impl ExtensionsPage {
 
             let mut this = Self {
                 workspace: workspace.weak_handle(),
-                list: scroll_handle.clone(),
+                list: scroll_handle,
                 is_fetching_extensions: false,
                 filter: ExtensionFilter::All,
                 dev_extension_entries: Vec::new(),
@@ -351,7 +350,6 @@ impl ExtensionsPage {
                 _subscriptions: subscriptions,
                 query_editor,
                 upsells: BTreeSet::default(),
-                scrollbar_state: ScrollbarState::new(scroll_handle),
             };
             this.fetch_extensions(
                 this.search_query(cx),
@@ -1375,7 +1373,7 @@ impl ExtensionsPage {
 }
 
 impl Render for ExtensionsPage {
-    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
         v_flex()
             .size_full()
             .bg(cx.theme().colors().editor_background)
@@ -1520,25 +1518,24 @@ impl Render for ExtensionsPage {
                         }
 
                         if count == 0 {
-                            return this.py_4().child(self.render_empty_state(cx));
-                        }
-
-                        let scroll_handle = self.list.clone();
-                        this.child(
-                            uniform_list("entries", count, cx.processor(Self::render_extensions))
+                            this.py_4()
+                                .child(self.render_empty_state(cx))
+                                .into_any_element()
+                        } else {
+                            let scroll_handle = self.list.clone();
+                            this.child(
+                                uniform_list(
+                                    "entries",
+                                    count,
+                                    cx.processor(Self::render_extensions),
+                                )
                                 .flex_grow()
                                 .pb_4()
-                                .track_scroll(scroll_handle),
-                        )
-                        .child(
-                            div()
-                                .absolute()
-                                .right_1()
-                                .top_0()
-                                .bottom_0()
-                                .w(px(12.))
-                                .children(Scrollbar::vertical(self.scrollbar_state.clone())),
-                        )
+                                .track_scroll(scroll_handle.clone()),
+                            )
+                            .vertical_scrollbar_for(scroll_handle, window, cx)
+                            .into_any_element()
+                        }
                     }),
             )
     }

crates/git_ui/src/git_panel.rs 🔗

@@ -13,10 +13,7 @@ use agent_settings::AgentSettings;
 use anyhow::Context as _;
 use askpass::AskPassDelegate;
 use db::kvp::KEY_VALUE_STORE;
-use editor::{
-    Editor, EditorElement, EditorMode, EditorSettings, MultiBuffer, ShowScrollbar,
-    scroll::ScrollbarAutoHide,
-};
+use editor::{Editor, EditorElement, EditorMode, MultiBuffer};
 use futures::StreamExt as _;
 use git::blame::ParsedCommitMessage;
 use git::repository::{
@@ -32,11 +29,10 @@ use git::{
     TrashUntrackedFiles, UnstageAll,
 };
 use gpui::{
-    Action, AsyncApp, AsyncWindowContext, Axis, ClickEvent, Corner, DismissEvent, Entity,
-    EventEmitter, FocusHandle, Focusable, KeyContext, ListHorizontalSizingBehavior,
-    ListSizingBehavior, MouseButton, MouseDownEvent, Point, PromptLevel, ScrollStrategy,
-    Subscription, Task, UniformListScrollHandle, WeakEntity, actions, anchored, deferred,
-    uniform_list,
+    Action, AsyncApp, AsyncWindowContext, ClickEvent, Corner, DismissEvent, Entity, EventEmitter,
+    FocusHandle, Focusable, KeyContext, ListHorizontalSizingBehavior, ListSizingBehavior,
+    MouseButton, MouseDownEvent, Point, PromptLevel, ScrollStrategy, Subscription, Task,
+    UniformListScrollHandle, WeakEntity, actions, anchored, deferred, uniform_list,
 };
 use itertools::Itertools;
 use language::{Buffer, File};
@@ -64,7 +60,7 @@ use strum::{IntoEnumIterator, VariantNames};
 use time::OffsetDateTime;
 use ui::{
     Checkbox, CommonAnimationExt, ContextMenu, ElevationIndex, IconPosition, Label, LabelSize,
-    PopoverMenu, Scrollbar, ScrollbarState, SplitButton, Tooltip, prelude::*,
+    PopoverMenu, ScrollAxes, Scrollbars, SplitButton, Tooltip, WithScrollbar, prelude::*,
 };
 use util::{ResultExt, TryFutureExt, maybe};
 use workspace::SERIALIZATION_THROTTLE_TIME;
@@ -291,61 +287,6 @@ struct PendingOperation {
     op_id: usize,
 }
 
-// computed state related to how to render scrollbars
-// one per axis
-// on render we just read this off the panel
-// we update it when
-// - settings change
-// - on focus in, on focus out, on hover, etc.
-#[derive(Debug)]
-struct ScrollbarProperties {
-    axis: Axis,
-    show_scrollbar: bool,
-    show_track: bool,
-    auto_hide: bool,
-    hide_task: Option<Task<()>>,
-    state: ScrollbarState,
-}
-
-impl ScrollbarProperties {
-    // Shows the scrollbar and cancels any pending hide task
-    fn show(&mut self, cx: &mut Context<GitPanel>) {
-        if !self.auto_hide {
-            return;
-        }
-        self.show_scrollbar = true;
-        self.hide_task.take();
-        cx.notify();
-    }
-
-    fn hide(&mut self, window: &mut Window, cx: &mut Context<GitPanel>) {
-        const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
-
-        if !self.auto_hide {
-            return;
-        }
-
-        let axis = self.axis;
-        self.hide_task = Some(cx.spawn_in(window, async move |panel, cx| {
-            cx.background_executor()
-                .timer(SCROLLBAR_SHOW_INTERVAL)
-                .await;
-
-            if let Some(panel) = panel.upgrade() {
-                panel
-                    .update(cx, |panel, cx| {
-                        match axis {
-                            Axis::Vertical => panel.vertical_scrollbar.show_scrollbar = false,
-                            Axis::Horizontal => panel.horizontal_scrollbar.show_scrollbar = false,
-                        }
-                        cx.notify();
-                    })
-                    .log_err();
-            }
-        }));
-    }
-}
-
 pub struct GitPanel {
     pub(crate) active_repository: Option<Entity<Repository>>,
     pub(crate) commit_editor: Entity<Editor>,
@@ -358,8 +299,6 @@ pub struct GitPanel {
     single_tracked_entry: Option<GitStatusEntry>,
     focus_handle: FocusHandle,
     fs: Arc<dyn Fs>,
-    horizontal_scrollbar: ScrollbarProperties,
-    vertical_scrollbar: ScrollbarProperties,
     new_count: usize,
     entry_count: usize,
     new_staged_count: usize,
@@ -443,10 +382,6 @@ impl GitPanel {
         cx.new(|cx| {
             let focus_handle = cx.focus_handle();
             cx.on_focus(&focus_handle, window, Self::focus_in).detach();
-            cx.on_focus_out(&focus_handle, window, |this, _, window, cx| {
-                this.hide_scrollbars(window, cx);
-            })
-            .detach();
 
             let mut was_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
             cx.observe_global_in::<SettingsStore>(window, move |this, window, cx| {
@@ -471,24 +406,6 @@ impl GitPanel {
 
             let scroll_handle = UniformListScrollHandle::new();
 
-            let vertical_scrollbar = ScrollbarProperties {
-                axis: Axis::Vertical,
-                state: ScrollbarState::new(scroll_handle.clone()).parent_entity(&cx.entity()),
-                show_scrollbar: false,
-                show_track: false,
-                auto_hide: false,
-                hide_task: None,
-            };
-
-            let horizontal_scrollbar = ScrollbarProperties {
-                axis: Axis::Horizontal,
-                state: ScrollbarState::new(scroll_handle.clone()).parent_entity(&cx.entity()),
-                show_scrollbar: false,
-                show_track: false,
-                auto_hide: false,
-                hide_task: None,
-            };
-
             let mut assistant_enabled = AgentSettings::get_global(cx).enabled;
             let mut was_ai_disabled = DisableAiSettings::get_global(cx).disable_ai;
             let _settings_subscription = cx.observe_global::<SettingsStore>(move |_, cx| {
@@ -570,8 +487,6 @@ impl GitPanel {
                 workspace: workspace.weak_handle(),
                 modal_open: false,
                 entry_count: 0,
-                horizontal_scrollbar,
-                vertical_scrollbar,
                 bulk_staging: None,
                 stash_entries: Default::default(),
                 _settings_subscription,
@@ -582,86 +497,6 @@ impl GitPanel {
         })
     }
 
-    fn hide_scrollbars(&mut self, window: &mut Window, cx: &mut Context<Self>) {
-        self.horizontal_scrollbar.hide(window, cx);
-        self.vertical_scrollbar.hide(window, cx);
-    }
-
-    fn update_scrollbar_properties(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
-        // TODO: This PR should have defined Editor's `scrollbar.axis`
-        // as an Option<ScrollbarAxis>, not a ScrollbarAxes as it would allow you to
-        // `.unwrap_or(EditorSettings::get_global(cx).scrollbar.show)`.
-        //
-        // Once this is fixed we can extend the GitPanelSettings with a `scrollbar.axis`
-        // so we can show each axis based on the settings.
-        //
-        // We should fix this. PR: https://github.com/zed-industries/zed/pull/19495
-
-        let show_setting = GitPanelSettings::get_global(cx)
-            .scrollbar
-            .show
-            .unwrap_or(EditorSettings::get_global(cx).scrollbar.show);
-
-        let scroll_handle = self.scroll_handle.0.borrow();
-
-        let autohide = |show: ShowScrollbar, cx: &mut Context<Self>| match show {
-            ShowScrollbar::Auto => true,
-            ShowScrollbar::System => cx
-                .try_global::<ScrollbarAutoHide>()
-                .map_or_else(|| cx.should_auto_hide_scrollbars(), |autohide| autohide.0),
-            ShowScrollbar::Always => false,
-            ShowScrollbar::Never => false,
-        };
-
-        let longest_item_width = scroll_handle.last_item_size.and_then(|size| {
-            (size.contents.width > size.item.width).then_some(size.contents.width)
-        });
-
-        // is there an item long enough that we should show a horizontal scrollbar?
-        let item_wider_than_container = if let Some(longest_item_width) = longest_item_width {
-            longest_item_width > px(scroll_handle.base_handle.bounds().size.width.0)
-        } else {
-            true
-        };
-
-        let show_horizontal = match (show_setting, item_wider_than_container) {
-            (_, false) => false,
-            (ShowScrollbar::Auto | ShowScrollbar::System | ShowScrollbar::Always, true) => true,
-            (ShowScrollbar::Never, true) => false,
-        };
-
-        let show_vertical = match show_setting {
-            ShowScrollbar::Auto | ShowScrollbar::System | ShowScrollbar::Always => true,
-            ShowScrollbar::Never => false,
-        };
-
-        let show_horizontal_track =
-            show_horizontal && matches!(show_setting, ShowScrollbar::Always);
-
-        // TODO: we probably should hide the scroll track when the list doesn't need to scroll
-        let show_vertical_track = show_vertical && matches!(show_setting, ShowScrollbar::Always);
-
-        self.vertical_scrollbar = ScrollbarProperties {
-            axis: self.vertical_scrollbar.axis,
-            state: self.vertical_scrollbar.state.clone(),
-            show_scrollbar: show_vertical,
-            show_track: show_vertical_track,
-            auto_hide: autohide(show_setting, cx),
-            hide_task: None,
-        };
-
-        self.horizontal_scrollbar = ScrollbarProperties {
-            axis: self.horizontal_scrollbar.axis,
-            state: self.horizontal_scrollbar.state.clone(),
-            show_scrollbar: show_horizontal,
-            show_track: show_horizontal_track,
-            auto_hide: autohide(show_setting, cx),
-            hide_task: None,
-        };
-
-        cx.notify();
-    }
-
     pub fn entry_by_path(&self, path: &RepoPath, cx: &App) -> Option<usize> {
         if GitPanelSettings::get_global(cx).sort_by_path {
             return self
@@ -2676,7 +2511,6 @@ impl GitPanel {
                             git_panel.clear_pending();
                         }
                         git_panel.update_visible_entries(window, cx);
-                        git_panel.update_scrollbar_properties(window, cx);
                     })
                     .ok();
             }
@@ -3799,110 +3633,6 @@ impl GitPanel {
         )
     }
 
-    fn render_vertical_scrollbar(
-        &self,
-        show_horizontal_scrollbar_container: bool,
-        cx: &mut Context<Self>,
-    ) -> impl IntoElement {
-        div()
-            .id("git-panel-vertical-scroll")
-            .occlude()
-            .flex_none()
-            .h_full()
-            .cursor_default()
-            .absolute()
-            .right_0()
-            .top_0()
-            .bottom_0()
-            .w(px(12.))
-            .when(show_horizontal_scrollbar_container, |this| {
-                this.pb_neg_3p5()
-            })
-            .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_mouse_up(
-                MouseButton::Left,
-                cx.listener(|this, _, window, cx| {
-                    if !this.vertical_scrollbar.state.is_dragging()
-                        && !this.focus_handle.contains_focused(window, cx)
-                    {
-                        this.vertical_scrollbar.hide(window, cx);
-                        cx.notify();
-                    }
-
-                    cx.stop_propagation();
-                }),
-            )
-            .on_scroll_wheel(cx.listener(|_, _, _, cx| {
-                cx.notify();
-            }))
-            .children(Scrollbar::vertical(
-                // percentage as f32..end_offset as f32,
-                self.vertical_scrollbar.state.clone(),
-            ))
-    }
-
-    /// Renders the horizontal scrollbar.
-    ///
-    /// The right offset is used to determine how far to the right the
-    /// scrollbar should extend to, useful for ensuring it doesn't collide
-    /// with the vertical scrollbar when visible.
-    fn render_horizontal_scrollbar(
-        &self,
-        right_offset: Pixels,
-        cx: &mut Context<Self>,
-    ) -> impl IntoElement {
-        div()
-            .id("git-panel-horizontal-scroll")
-            .occlude()
-            .flex_none()
-            .w_full()
-            .cursor_default()
-            .absolute()
-            .bottom_neg_px()
-            .left_0()
-            .right_0()
-            .pr(right_offset)
-            .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_mouse_up(
-                MouseButton::Left,
-                cx.listener(|this, _, window, cx| {
-                    if !this.horizontal_scrollbar.state.is_dragging()
-                        && !this.focus_handle.contains_focused(window, cx)
-                    {
-                        this.horizontal_scrollbar.hide(window, cx);
-                        cx.notify();
-                    }
-
-                    cx.stop_propagation();
-                }),
-            )
-            .on_scroll_wheel(cx.listener(|_, _, _, cx| {
-                cx.notify();
-            }))
-            .children(Scrollbar::horizontal(
-                // percentage as f32..end_offset as f32,
-                self.horizontal_scrollbar.state.clone(),
-            ))
-    }
-
     fn render_buffer_header_controls(
         &self,
         entity: &Entity<Self>,
@@ -3950,33 +3680,16 @@ impl GitPanel {
     fn render_entries(
         &self,
         has_write_access: bool,
-        _: &Window,
+        window: &mut Window,
         cx: &mut Context<Self>,
     ) -> impl IntoElement {
         let entry_count = self.entries.len();
 
-        let scroll_track_size = px(16.);
-
-        let h_scroll_offset = if self.vertical_scrollbar.show_scrollbar {
-            // magic number
-            px(3.)
-        } else {
-            px(0.)
-        };
-
         v_flex()
             .flex_1()
             .size_full()
             .overflow_hidden()
             .relative()
-            // Show a border on the top and bottom of the container when
-            // the vertical scrollbar container is visible so we don't have a
-            // floating left border in the panel.
-            .when(self.vertical_scrollbar.show_track, |this| {
-                this.border_t_1()
-                    .border_b_1()
-                    .border_color(cx.theme().colors().border)
-            })
             .child(
                 h_flex()
                     .flex_1()
@@ -4017,15 +3730,6 @@ impl GitPanel {
                                 items
                             }),
                         )
-                        .when(
-                            !self.horizontal_scrollbar.show_track
-                                && self.horizontal_scrollbar.show_scrollbar,
-                            |this| {
-                                // when not showing the horizontal scrollbar track, make sure we don't
-                                // obscure the last entry
-                                this.pb(scroll_track_size)
-                            },
-                        )
                         .size_full()
                         .flex_grow()
                         .with_sizing_behavior(ListSizingBehavior::Auto)
@@ -4041,72 +3745,14 @@ impl GitPanel {
                             this.deploy_panel_context_menu(event.position, window, cx)
                         }),
                     )
-                    .when(self.vertical_scrollbar.show_track, |this| {
-                        this.child(
-                            v_flex()
-                                .h_full()
-                                .flex_none()
-                                .w(scroll_track_size)
-                                .bg(cx.theme().colors().panel_background)
-                                .child(
-                                    div()
-                                        .size_full()
-                                        .flex_1()
-                                        .border_l_1()
-                                        .border_color(cx.theme().colors().border),
-                                ),
-                        )
-                    })
-                    .when(self.vertical_scrollbar.show_scrollbar, |this| {
-                        this.child(
-                            self.render_vertical_scrollbar(
-                                self.horizontal_scrollbar.show_track,
-                                cx,
-                            ),
-                        )
-                    }),
+                    .custom_scrollbars(
+                        Scrollbars::for_settings::<GitPanelSettings>()
+                            .tracked_scroll_handle(self.scroll_handle.clone())
+                            .with_track_along(ScrollAxes::Horizontal),
+                        window,
+                        cx,
+                    ),
             )
-            .when(self.horizontal_scrollbar.show_track, |this| {
-                this.child(
-                    h_flex()
-                        .w_full()
-                        .h(scroll_track_size)
-                        .flex_none()
-                        .relative()
-                        .child(
-                            div()
-                                .w_full()
-                                .flex_1()
-                                // for some reason the horizontal scrollbar is 1px
-                                // taller than the vertical scrollbar??
-                                .h(scroll_track_size - px(1.))
-                                .bg(cx.theme().colors().panel_background)
-                                .border_t_1()
-                                .border_color(cx.theme().colors().border),
-                        )
-                        .when(self.vertical_scrollbar.show_track, |this| {
-                            this.child(
-                                div()
-                                    .flex_none()
-                                    // -1px prevents a missing pixel between the two container borders
-                                    .w(scroll_track_size - px(1.))
-                                    .h_full(),
-                            )
-                            .child(
-                                // HACK: Fill the missing 1px 🥲
-                                div()
-                                    .absolute()
-                                    .right(scroll_track_size - px(1.))
-                                    .bottom(scroll_track_size - px(1.))
-                                    .size_px()
-                                    .bg(cx.theme().colors().border),
-                            )
-                        }),
-                )
-            })
-            .when(self.horizontal_scrollbar.show_scrollbar, |this| {
-                this.child(self.render_horizontal_scrollbar(h_scroll_offset, cx))
-            })
     }
 
     fn entry_label(&self, label: impl Into<SharedString>, color: Color) -> Label {
@@ -4620,15 +4266,6 @@ impl Render for GitPanel {
                 git_panel.on_action(cx.listener(Self::toggle_fill_co_authors))
             })
             .on_action(cx.listener(Self::toggle_sort_by_path))
-            .on_hover(cx.listener(move |this, hovered, window, cx| {
-                if *hovered {
-                    this.horizontal_scrollbar.show(cx);
-                    this.vertical_scrollbar.show(cx);
-                    cx.notify();
-                } else if !this.focus_handle.contains_focused(window, cx) {
-                    this.hide_scrollbars(window, cx);
-                }
-            }))
             .size_full()
             .overflow_hidden()
             .bg(cx.theme().colors().panel_background)

crates/git_ui/src/git_panel_settings.rs 🔗

@@ -1,8 +1,9 @@
-use editor::ShowScrollbar;
+use editor::EditorSettings;
 use gpui::Pixels;
 use schemars::JsonSchema;
 use serde_derive::{Deserialize, Serialize};
 use settings::{Settings, SettingsKey, SettingsSources, SettingsUi};
+use ui::scrollbars::{ScrollbarVisibility, ShowScrollbar};
 use workspace::dock::DockPosition;
 
 #[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
@@ -90,6 +91,22 @@ pub struct GitPanelSettings {
     pub collapse_untracked_diff: bool,
 }
 
+impl ScrollbarVisibility for GitPanelSettings {
+    fn visibility(&self, cx: &ui::App) -> ShowScrollbar {
+        // TODO: This PR should have defined Editor's `scrollbar.axis`
+        // as an Option<ScrollbarAxis>, not a ScrollbarAxes as it would allow you to
+        // `.unwrap_or(EditorSettings::get_global(cx).scrollbar.show)`.
+        //
+        // Once this is fixed we can extend the GitPanelSettings with a `scrollbar.axis`
+        // so we can show each axis based on the settings.
+        //
+        // We should fix this. PR: https://github.com/zed-industries/zed/pull/19495
+        self.scrollbar
+            .show
+            .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show)
+    }
+}
+
 impl Settings for GitPanelSettings {
     type FileContent = GitPanelSettingsContent;
 

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

@@ -16,10 +16,10 @@
 //! constructed by combining these two systems into an all-in-one element.
 
 use crate::{
-    Action, AnyDrag, AnyElement, AnyTooltip, AnyView, App, Bounds, ClickEvent, DispatchPhase,
-    Element, ElementId, Entity, FocusHandle, Global, GlobalElementId, Hitbox, HitboxBehavior,
-    HitboxId, InspectorElementId, IntoElement, IsZero, KeyContext, KeyDownEvent, KeyUpEvent,
-    KeyboardButton, KeyboardClickEvent, LayoutId, ModifiersChangedEvent, MouseButton,
+    AbsoluteLength, Action, AnyDrag, AnyElement, AnyTooltip, AnyView, App, Bounds, ClickEvent,
+    DispatchPhase, Element, ElementId, Entity, FocusHandle, Global, GlobalElementId, Hitbox,
+    HitboxBehavior, HitboxId, InspectorElementId, IntoElement, IsZero, KeyContext, KeyDownEvent,
+    KeyUpEvent, KeyboardButton, KeyboardClickEvent, LayoutId, ModifiersChangedEvent, MouseButton,
     MouseClickEvent, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Overflow, ParentElement, Pixels,
     Point, Render, ScrollWheelEvent, SharedString, Size, Style, StyleRefinement, Styled, Task,
     TooltipId, Visibility, Window, WindowControlArea, point, px, size,
@@ -1036,6 +1036,15 @@ pub trait StatefulInteractiveElement: InteractiveElement {
         self
     }
 
+    /// Set the space to be reserved for rendering the scrollbar.
+    ///
+    /// This will only affect the layout of the element when overflow for this element is set to
+    /// `Overflow::Scroll`.
+    fn scrollbar_width(mut self, width: impl Into<AbsoluteLength>) -> Self {
+        self.interactivity().base_style.scrollbar_width = Some(width.into());
+        self
+    }
+
     /// Track the scroll state of this element with the given handle.
     fn track_scroll(mut self, scroll_handle: &ScrollHandle) -> Self {
         self.interactivity().tracked_scroll_handle = Some(scroll_handle.clone());

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

@@ -5,10 +5,10 @@
 //! elements with uniform height.
 
 use crate::{
-    AnyElement, App, AvailableSpace, Bounds, ContentMask, Element, ElementId, GlobalElementId,
-    Hitbox, InspectorElementId, InteractiveElement, Interactivity, IntoElement, IsZero, LayoutId,
-    ListSizingBehavior, Overflow, Pixels, Point, ScrollHandle, Size, StyleRefinement, Styled,
-    Window, point, size,
+    AnyElement, App, AvailableSpace, Bounds, ContentMask, Element, ElementId, Entity,
+    GlobalElementId, Hitbox, InspectorElementId, InteractiveElement, Interactivity, IntoElement,
+    IsZero, LayoutId, ListSizingBehavior, Overflow, Pixels, Point, ScrollHandle, Size,
+    StyleRefinement, Styled, Window, point, size,
 };
 use smallvec::SmallVec;
 use std::{cell::RefCell, cmp, ops::Range, rc::Rc};
@@ -71,7 +71,7 @@ pub struct UniformList {
 /// Frame state used by the [UniformList].
 pub struct UniformListFrameState {
     items: SmallVec<[AnyElement; 32]>,
-    decorations: SmallVec<[AnyElement; 1]>,
+    decorations: SmallVec<[AnyElement; 2]>,
 }
 
 /// A handle for controlling the scroll position of a uniform list.
@@ -529,6 +529,31 @@ pub trait UniformListDecoration {
     ) -> AnyElement;
 }
 
+impl<T: UniformListDecoration + 'static> UniformListDecoration for Entity<T> {
+    fn compute(
+        &self,
+        visible_range: Range<usize>,
+        bounds: Bounds<Pixels>,
+        scroll_offset: Point<Pixels>,
+        item_height: Pixels,
+        item_count: usize,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> AnyElement {
+        self.update(cx, |inner, cx| {
+            inner.compute(
+                visible_range,
+                bounds,
+                scroll_offset,
+                item_height,
+                item_count,
+                window,
+                cx,
+            )
+        })
+    }
+}
+
 impl UniformList {
     /// Selects a specific list item for measurement.
     pub fn with_width_from_item(mut self, item_index: Option<usize>) -> Self {

crates/gpui/src/style.rs 🔗

@@ -153,7 +153,7 @@ pub struct Style {
     #[refineable]
     pub overflow: Point<Overflow>,
     /// How much space (in points) should be reserved for the scrollbars of `Overflow::Scroll` and `Overflow::Auto` nodes.
-    pub scrollbar_width: f32,
+    pub scrollbar_width: AbsoluteLength,
     /// Whether both x and y axis should be scrollable at the same time.
     pub allow_concurrent_scroll: bool,
     /// Whether scrolling should be restricted to the axis indicated by the mouse wheel.
@@ -745,7 +745,7 @@ impl Default for Style {
             },
             allow_concurrent_scroll: false,
             restrict_scroll_to_axis: false,
-            scrollbar_width: 0.0,
+            scrollbar_width: AbsoluteLength::default(),
             position: Position::Relative,
             inset: Edges::auto(),
             margin: Edges::<Length>::zero(),

crates/gpui/src/taffy.rs 🔗

@@ -277,7 +277,7 @@ impl ToTaffy<taffy::style::Style> for Style {
         taffy::style::Style {
             display: self.display.into(),
             overflow: self.overflow.into(),
-            scrollbar_width: self.scrollbar_width,
+            scrollbar_width: self.scrollbar_width.to_taffy(rem_size),
             position: self.position.into(),
             inset: self.inset.to_taffy(rem_size),
             size: self.size.to_taffy(rem_size),
@@ -314,6 +314,15 @@ impl ToTaffy<taffy::style::Style> for Style {
     }
 }
 
+impl ToTaffy<f32> for AbsoluteLength {
+    fn to_taffy(&self, rem_size: Pixels) -> f32 {
+        match self {
+            AbsoluteLength::Pixels(pixels) => pixels.into(),
+            AbsoluteLength::Rems(rems) => (*rems * rem_size).into(),
+        }
+    }
+}
+
 impl ToTaffy<taffy::style::LengthPercentageAuto> for Length {
     fn to_taffy(&self, rem_size: Pixels) -> taffy::prelude::LengthPercentageAuto {
         match self {

crates/gpui/src/window.rs 🔗

@@ -4960,6 +4960,12 @@ impl<T: Into<SharedString>> From<(ElementId, T)> for ElementId {
     }
 }
 
+impl From<&'static core::panic::Location<'static>> for ElementId {
+    fn from(location: &'static core::panic::Location<'static>) -> Self {
+        ElementId::CodeLocation(*location)
+    }
+}
+
 /// A rectangle to be rendered in the window at the given position and size.
 /// Passed as an argument [`Window::paint_quad`].
 #[derive(Clone)]

crates/keymap_editor/src/keymap_editor.rs 🔗

@@ -13,8 +13,8 @@ use editor::{CompletionProvider, Editor, EditorEvent};
 use fs::Fs;
 use fuzzy::{StringMatch, StringMatchCandidate};
 use gpui::{
-    Action, AppContext as _, AsyncApp, Axis, ClickEvent, Context, DismissEvent, Entity,
-    EventEmitter, FocusHandle, Focusable, Global, IsZero,
+    Action, AppContext as _, AsyncApp, ClickEvent, Context, DismissEvent, Entity, EventEmitter,
+    FocusHandle, Focusable, Global, IsZero,
     KeyBindingContextPredicate::{And, Descendant, Equal, Identifier, Not, NotEqual, Or},
     KeyContext, KeybindingKeystroke, MouseButton, PlatformKeyboardMapper, Point, ScrollStrategy,
     ScrollWheelEvent, Stateful, StyledText, Subscription, Task, TextStyleRefinement, WeakEntity,
@@ -425,7 +425,7 @@ impl KeymapEditor {
     fn new(workspace: WeakEntity<Workspace>, window: &mut Window, cx: &mut Context<Self>) -> Self {
         let _keymap_subscription =
             cx.observe_global_in::<KeymapEventChannel>(window, Self::on_keymap_changed);
-        let table_interaction_state = TableInteractionState::new(window, cx);
+        let table_interaction_state = TableInteractionState::new(cx);
 
         let keystroke_editor = cx.new(|cx| {
             let mut keystroke_editor = KeystrokeInput::new(None, window, cx);
@@ -781,9 +781,8 @@ impl KeymapEditor {
                     match previous_edit {
                         // should remove scroll from process_query
                         PreviousEdit::ScrollBarOffset(offset) => {
-                            this.table_interaction_state.update(cx, |table, _| {
-                                table.set_scrollbar_offset(Axis::Vertical, offset)
-                            })
+                            this.table_interaction_state
+                                .update(cx, |table, _| table.set_scroll_offset(offset))
                             // set selected index and scroll
                         }
                         PreviousEdit::Keybinding {
@@ -812,9 +811,8 @@ impl KeymapEditor {
                                     cx,
                                 );
                             } else {
-                                this.table_interaction_state.update(cx, |table, _| {
-                                    table.set_scrollbar_offset(Axis::Vertical, fallback)
-                                });
+                                this.table_interaction_state
+                                    .update(cx, |table, _| table.set_scroll_offset(fallback));
                             }
                             cx.notify();
                         }
@@ -1199,9 +1197,7 @@ impl KeymapEditor {
         };
         let tab_size = cx.global::<settings::SettingsStore>().json_tab_size();
         self.previous_edit = Some(PreviousEdit::ScrollBarOffset(
-            self.table_interaction_state
-                .read(cx)
-                .get_scrollbar_offset(Axis::Vertical),
+            self.table_interaction_state.read(cx).scroll_offset(),
         ));
         let keyboard_mapper = cx.keyboard_mapper().clone();
         cx.spawn(async move |_, _| {
@@ -2374,10 +2370,7 @@ impl KeybindingEditorModal {
                             keymap.previous_edit = Some(PreviousEdit::Keybinding {
                                 action_mapping,
                                 action_name,
-                                fallback: keymap
-                                    .table_interaction_state
-                                    .read(cx)
-                                    .get_scrollbar_offset(Axis::Vertical),
+                                fallback: keymap.table_interaction_state.read(cx).scroll_offset(),
                             });
                             let status_toast = StatusToast::new(
                                 format!("Saved edits to the {} action.", humanized_action_name),

crates/keymap_editor/src/ui_components/table.rs 🔗

@@ -1,20 +1,20 @@
-use std::{ops::Range, rc::Rc, time::Duration};
+use std::{ops::Range, rc::Rc};
 
-use editor::{EditorSettings, ShowScrollbar, scroll::ScrollbarAutoHide};
+use editor::EditorSettings;
 use gpui::{
-    AbsoluteLength, AppContext, Axis, Context, DefiniteLength, DragMoveEvent, Entity, EntityId,
-    FocusHandle, Length, ListHorizontalSizingBehavior, ListSizingBehavior, MouseButton, Point,
-    Stateful, Task, UniformListScrollHandle, WeakEntity, transparent_black, uniform_list,
+    AbsoluteLength, AppContext, Context, DefiniteLength, DragMoveEvent, Entity, EntityId,
+    FocusHandle, Length, ListHorizontalSizingBehavior, ListSizingBehavior, Point, Stateful,
+    UniformListScrollHandle, WeakEntity, transparent_black, uniform_list,
 };
 
 use itertools::intersperse_with;
-use settings::Settings as _;
 use ui::{
     ActiveTheme as _, AnyElement, App, Button, ButtonCommon as _, ButtonStyle, Color, Component,
     ComponentScope, Div, ElementId, FixedWidth as _, FluentBuilder as _, Indicator,
     InteractiveElement, IntoElement, ParentElement, Pixels, RegisterComponent, RenderOnce,
-    Scrollbar, ScrollbarState, SharedString, StatefulInteractiveElement, Styled, StyledExt as _,
-    StyledTypography, Window, div, example_group_with_title, h_flex, px, single_example, v_flex,
+    ScrollableHandle, Scrollbars, SharedString, StatefulInteractiveElement, Styled, StyledExt as _,
+    StyledTypography, Window, WithScrollbar, div, example_group_with_title, h_flex, px,
+    single_example, v_flex,
 };
 
 const RESIZE_COLUMN_WIDTH: f32 = 8.0;
@@ -56,136 +56,22 @@ impl<const COLS: usize> TableContents<COLS> {
 pub struct TableInteractionState {
     pub focus_handle: FocusHandle,
     pub scroll_handle: UniformListScrollHandle,
-    pub horizontal_scrollbar: ScrollbarProperties,
-    pub vertical_scrollbar: ScrollbarProperties,
 }
 
 impl TableInteractionState {
-    pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
-        cx.new(|cx| {
-            let focus_handle = cx.focus_handle();
-
-            cx.on_focus_out(&focus_handle, window, |this: &mut Self, _, window, cx| {
-                this.hide_scrollbars(window, cx);
-            })
-            .detach();
-
-            let scroll_handle = UniformListScrollHandle::new();
-            let vertical_scrollbar = ScrollbarProperties {
-                axis: Axis::Vertical,
-                state: ScrollbarState::new(scroll_handle.clone()).parent_entity(&cx.entity()),
-                show_scrollbar: false,
-                show_track: false,
-                auto_hide: false,
-                hide_task: None,
-            };
-
-            let horizontal_scrollbar = ScrollbarProperties {
-                axis: Axis::Horizontal,
-                state: ScrollbarState::new(scroll_handle.clone()).parent_entity(&cx.entity()),
-                show_scrollbar: false,
-                show_track: false,
-                auto_hide: false,
-                hide_task: None,
-            };
-
-            let mut this = Self {
-                focus_handle,
-                scroll_handle,
-                horizontal_scrollbar,
-                vertical_scrollbar,
-            };
-
-            this.update_scrollbar_visibility(cx);
-            this
+    pub fn new(cx: &mut App) -> Entity<Self> {
+        cx.new(|cx| Self {
+            focus_handle: cx.focus_handle(),
+            scroll_handle: UniformListScrollHandle::new(),
         })
     }
 
-    pub fn get_scrollbar_offset(&self, axis: Axis) -> Point<Pixels> {
-        match axis {
-            Axis::Vertical => self.vertical_scrollbar.state.scroll_handle().offset(),
-            Axis::Horizontal => self.horizontal_scrollbar.state.scroll_handle().offset(),
-        }
-    }
-
-    pub fn set_scrollbar_offset(&self, axis: Axis, offset: Point<Pixels>) {
-        match axis {
-            Axis::Vertical => self
-                .vertical_scrollbar
-                .state
-                .scroll_handle()
-                .set_offset(offset),
-            Axis::Horizontal => self
-                .horizontal_scrollbar
-                .state
-                .scroll_handle()
-                .set_offset(offset),
-        }
-    }
-
-    fn update_scrollbar_visibility(&mut self, cx: &mut Context<Self>) {
-        let show_setting = EditorSettings::get_global(cx).scrollbar.show;
-
-        let scroll_handle = self.scroll_handle.0.borrow();
-
-        let autohide = |show: ShowScrollbar, cx: &mut Context<Self>| match show {
-            ShowScrollbar::Auto => true,
-            ShowScrollbar::System => cx
-                .try_global::<ScrollbarAutoHide>()
-                .map_or_else(|| cx.should_auto_hide_scrollbars(), |autohide| autohide.0),
-            ShowScrollbar::Always => false,
-            ShowScrollbar::Never => false,
-        };
-
-        let longest_item_width = scroll_handle.last_item_size.and_then(|size| {
-            (size.contents.width > size.item.width).then_some(size.contents.width)
-        });
-
-        // is there an item long enough that we should show a horizontal scrollbar?
-        let item_wider_than_container = if let Some(longest_item_width) = longest_item_width {
-            longest_item_width > px(scroll_handle.base_handle.bounds().size.width.0)
-        } else {
-            true
-        };
-
-        let show_scrollbar = match show_setting {
-            ShowScrollbar::Auto | ShowScrollbar::System | ShowScrollbar::Always => true,
-            ShowScrollbar::Never => false,
-        };
-        let show_vertical = show_scrollbar;
-
-        let show_horizontal = item_wider_than_container && show_scrollbar;
-
-        let show_horizontal_track =
-            show_horizontal && matches!(show_setting, ShowScrollbar::Always);
-
-        // TODO: we probably should hide the scroll track when the list doesn't need to scroll
-        let show_vertical_track = show_vertical && matches!(show_setting, ShowScrollbar::Always);
-
-        self.vertical_scrollbar = ScrollbarProperties {
-            axis: self.vertical_scrollbar.axis,
-            state: self.vertical_scrollbar.state.clone(),
-            show_scrollbar: show_vertical,
-            show_track: show_vertical_track,
-            auto_hide: autohide(show_setting, cx),
-            hide_task: None,
-        };
-
-        self.horizontal_scrollbar = ScrollbarProperties {
-            axis: self.horizontal_scrollbar.axis,
-            state: self.horizontal_scrollbar.state.clone(),
-            show_scrollbar: show_horizontal,
-            show_track: show_horizontal_track,
-            auto_hide: autohide(show_setting, cx),
-            hide_task: None,
-        };
-
-        cx.notify();
+    pub fn scroll_offset(&self) -> Point<Pixels> {
+        self.scroll_handle.offset()
     }
 
-    fn hide_scrollbars(&mut self, window: &mut Window, cx: &mut Context<Self>) {
-        self.horizontal_scrollbar.hide(window, cx);
-        self.vertical_scrollbar.hide(window, cx);
+    pub fn set_scroll_offset(&self, offset: Point<Pixels>) {
+        self.scroll_handle.set_offset(offset);
     }
 
     pub fn listener<E: ?Sized>(
@@ -280,183 +166,6 @@ impl TableInteractionState {
             .children(dividers)
             .into_any_element()
     }
-
-    fn render_vertical_scrollbar_track(
-        this: &Entity<Self>,
-        parent: Div,
-        scroll_track_size: Pixels,
-        cx: &mut App,
-    ) -> Div {
-        if !this.read(cx).vertical_scrollbar.show_track {
-            return parent;
-        }
-        let child = v_flex()
-            .h_full()
-            .flex_none()
-            .w(scroll_track_size)
-            .bg(cx.theme().colors().background)
-            .child(
-                div()
-                    .size_full()
-                    .flex_1()
-                    .border_l_1()
-                    .border_color(cx.theme().colors().border),
-            );
-        parent.child(child)
-    }
-
-    fn render_vertical_scrollbar(this: &Entity<Self>, parent: Div, cx: &mut App) -> Div {
-        if !this.read(cx).vertical_scrollbar.show_scrollbar {
-            return parent;
-        }
-        let child = div()
-            .id(("table-vertical-scrollbar", this.entity_id()))
-            .occlude()
-            .flex_none()
-            .h_full()
-            .cursor_default()
-            .absolute()
-            .right_0()
-            .top_0()
-            .bottom_0()
-            .w(px(12.))
-            .on_mouse_move(Self::listener(this, |_, _, _, cx| {
-                cx.notify();
-                cx.stop_propagation()
-            }))
-            .on_hover(|_, _, cx| {
-                cx.stop_propagation();
-            })
-            .on_mouse_up(
-                MouseButton::Left,
-                Self::listener(this, |this, _, window, cx| {
-                    if !this.vertical_scrollbar.state.is_dragging()
-                        && !this.focus_handle.contains_focused(window, cx)
-                    {
-                        this.vertical_scrollbar.hide(window, cx);
-                        cx.notify();
-                    }
-
-                    cx.stop_propagation();
-                }),
-            )
-            .on_any_mouse_down(|_, _, cx| {
-                cx.stop_propagation();
-            })
-            .on_scroll_wheel(Self::listener(this, |_, _, _, cx| {
-                cx.notify();
-            }))
-            .children(Scrollbar::vertical(
-                this.read(cx).vertical_scrollbar.state.clone(),
-            ));
-        parent.child(child)
-    }
-
-    /// Renders the horizontal scrollbar.
-    ///
-    /// The right offset is used to determine how far to the right the
-    /// scrollbar should extend to, useful for ensuring it doesn't collide
-    /// with the vertical scrollbar when visible.
-    fn render_horizontal_scrollbar(
-        this: &Entity<Self>,
-        parent: Div,
-        right_offset: Pixels,
-        cx: &mut App,
-    ) -> Div {
-        if !this.read(cx).horizontal_scrollbar.show_scrollbar {
-            return parent;
-        }
-        let child = div()
-            .id(("table-horizontal-scrollbar", this.entity_id()))
-            .occlude()
-            .flex_none()
-            .w_full()
-            .cursor_default()
-            .absolute()
-            .bottom_neg_px()
-            .left_0()
-            .right_0()
-            .pr(right_offset)
-            .on_mouse_move(Self::listener(this, |_, _, _, cx| {
-                cx.notify();
-                cx.stop_propagation()
-            }))
-            .on_hover(|_, _, cx| {
-                cx.stop_propagation();
-            })
-            .on_any_mouse_down(|_, _, cx| {
-                cx.stop_propagation();
-            })
-            .on_mouse_up(
-                MouseButton::Left,
-                Self::listener(this, |this, _, window, cx| {
-                    if !this.horizontal_scrollbar.state.is_dragging()
-                        && !this.focus_handle.contains_focused(window, cx)
-                    {
-                        this.horizontal_scrollbar.hide(window, cx);
-                        cx.notify();
-                    }
-
-                    cx.stop_propagation();
-                }),
-            )
-            .on_scroll_wheel(Self::listener(this, |_, _, _, cx| {
-                cx.notify();
-            }))
-            .children(Scrollbar::horizontal(
-                // percentage as f32..end_offset as f32,
-                this.read(cx).horizontal_scrollbar.state.clone(),
-            ));
-        parent.child(child)
-    }
-
-    fn render_horizontal_scrollbar_track(
-        this: &Entity<Self>,
-        parent: Div,
-        scroll_track_size: Pixels,
-        cx: &mut App,
-    ) -> Div {
-        if !this.read(cx).horizontal_scrollbar.show_track {
-            return parent;
-        }
-        let child = h_flex()
-            .w_full()
-            .h(scroll_track_size)
-            .flex_none()
-            .relative()
-            .child(
-                div()
-                    .w_full()
-                    .flex_1()
-                    // for some reason the horizontal scrollbar is 1px
-                    // taller than the vertical scrollbar??
-                    .h(scroll_track_size - px(1.))
-                    .bg(cx.theme().colors().background)
-                    .border_t_1()
-                    .border_color(cx.theme().colors().border),
-            )
-            .when(this.read(cx).vertical_scrollbar.show_track, |parent| {
-                parent
-                    .child(
-                        div()
-                            .flex_none()
-                            // -1px prevents a missing pixel between the two container borders
-                            .w(scroll_track_size - px(1.))
-                            .h_full(),
-                    )
-                    .child(
-                        // HACK: Fill the missing 1px 🥲
-                        div()
-                            .absolute()
-                            .right(scroll_track_size - px(1.))
-                            .bottom(scroll_track_size - px(1.))
-                            .size_px()
-                            .bg(cx.theme().colors().border),
-                    )
-            });
-
-        parent.child(child)
-    }
 }
 
 #[derive(Debug, Copy, Clone, PartialEq)]
@@ -1054,17 +763,6 @@ impl<const COLS: usize> RenderOnce for Table<COLS> {
             .and_then(|widths| Some((widths.current.as_ref()?, widths.resizable, widths.initial)))
             .map(|(curr, resize_behavior, initial)| (curr.downgrade(), resize_behavior, initial));
 
-        let scroll_track_size = px(16.);
-        let h_scroll_offset = if interaction_state
-            .as_ref()
-            .is_some_and(|state| state.read(cx).vertical_scrollbar.show_scrollbar)
-        {
-            // magic number
-            px(3.)
-        } else {
-            px(0.)
-        };
-
         let width = self.width;
         let no_rows_rendered = self.rows.is_empty();
 
@@ -1115,8 +813,8 @@ impl<const COLS: usize> RenderOnce for Table<COLS> {
                     })
                 }
             })
-            .child(
-                div()
+            .child({
+                let content = div()
                     .flex_grow()
                     .w_full()
                     .relative()
@@ -1187,25 +885,21 @@ impl<const COLS: usize> RenderOnce for Table<COLS> {
                                 )
                             }))
                         },
-                    )
-                    .when_some(interaction_state.as_ref(), |this, interaction_state| {
-                        this.map(|this| {
-                            TableInteractionState::render_vertical_scrollbar_track(
-                                interaction_state,
-                                this,
-                                scroll_track_size,
-                                cx,
-                            )
-                        })
-                        .map(|this| {
-                            TableInteractionState::render_vertical_scrollbar(
-                                interaction_state,
-                                this,
-                                cx,
-                            )
-                        })
-                    }),
-            )
+                    );
+
+                if let Some(state) = interaction_state.as_ref() {
+                    content
+                        .custom_scrollbars(
+                            Scrollbars::for_settings::<EditorSettings>()
+                                .tracked_scroll_handle(state.read(cx).scroll_handle.clone()),
+                            window,
+                            cx,
+                        )
+                        .into_any_element()
+                } else {
+                    content.into_any_element()
+                }
+            })
             .when_some(
                 no_rows_rendered
                     .then_some(self.empty_table_callback)
@@ -1220,52 +914,12 @@ impl<const COLS: usize> RenderOnce for Table<COLS> {
                             .child(callback(window, cx)),
                     )
                 },
-            )
-            .when_some(
-                width.and(interaction_state.as_ref()),
-                |this, interaction_state| {
-                    this.map(|this| {
-                        TableInteractionState::render_horizontal_scrollbar_track(
-                            interaction_state,
-                            this,
-                            scroll_track_size,
-                            cx,
-                        )
-                    })
-                    .map(|this| {
-                        TableInteractionState::render_horizontal_scrollbar(
-                            interaction_state,
-                            this,
-                            h_scroll_offset,
-                            cx,
-                        )
-                    })
-                },
             );
 
         if let Some(interaction_state) = interaction_state.as_ref() {
             table
                 .track_focus(&interaction_state.read(cx).focus_handle)
                 .id(("table", interaction_state.entity_id()))
-                .on_hover({
-                    let interaction_state = interaction_state.downgrade();
-                    move |hovered, window, cx| {
-                        interaction_state
-                            .update(cx, |interaction_state, cx| {
-                                if *hovered {
-                                    interaction_state.horizontal_scrollbar.show(cx);
-                                    interaction_state.vertical_scrollbar.show(cx);
-                                    cx.notify();
-                                } else if !interaction_state
-                                    .focus_handle
-                                    .contains_focused(window, cx)
-                                {
-                                    interaction_state.hide_scrollbars(window, cx);
-                                }
-                            })
-                            .ok();
-                    }
-                })
                 .into_any_element()
         } else {
             table.into_any_element()
@@ -1273,65 +927,6 @@ impl<const COLS: usize> RenderOnce for Table<COLS> {
     }
 }
 
-// computed state related to how to render scrollbars
-// one per axis
-// on render we just read this off the keymap editor
-// we update it when
-// - settings change
-// - on focus in, on focus out, on hover, etc.
-#[derive(Debug)]
-pub struct ScrollbarProperties {
-    axis: Axis,
-    show_scrollbar: bool,
-    show_track: bool,
-    auto_hide: bool,
-    hide_task: Option<Task<()>>,
-    state: ScrollbarState,
-}
-
-impl ScrollbarProperties {
-    // Shows the scrollbar and cancels any pending hide task
-    fn show(&mut self, cx: &mut Context<TableInteractionState>) {
-        if !self.auto_hide {
-            return;
-        }
-        self.show_scrollbar = true;
-        self.hide_task.take();
-        cx.notify();
-    }
-
-    fn hide(&mut self, window: &mut Window, cx: &mut Context<TableInteractionState>) {
-        const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
-
-        if !self.auto_hide {
-            return;
-        }
-
-        let axis = self.axis;
-        self.hide_task = Some(cx.spawn_in(window, async move |keymap_editor, cx| {
-            cx.background_executor()
-                .timer(SCROLLBAR_SHOW_INTERVAL)
-                .await;
-
-            if let Some(keymap_editor) = keymap_editor.upgrade() {
-                keymap_editor
-                    .update(cx, |keymap_editor, cx| {
-                        match axis {
-                            Axis::Vertical => {
-                                keymap_editor.vertical_scrollbar.show_scrollbar = false
-                            }
-                            Axis::Horizontal => {
-                                keymap_editor.horizontal_scrollbar.show_scrollbar = false
-                            }
-                        }
-                        cx.notify();
-                    })
-                    .ok();
-            }
-        }));
-    }
-}
-
 impl Component for Table<3> {
     fn scope() -> ComponentScope {
         ComponentScope::Layout

crates/outline_panel/src/outline_panel.rs 🔗

@@ -4,11 +4,11 @@ use anyhow::Context as _;
 use collections::{BTreeSet, HashMap, HashSet, hash_map};
 use db::kvp::KEY_VALUE_STORE;
 use editor::{
-    AnchorRangeExt, Bias, DisplayPoint, Editor, EditorEvent, EditorSettings, ExcerptId,
-    ExcerptRange, MultiBufferSnapshot, RangeToAnchorExt, SelectionEffects, ShowScrollbar,
+    AnchorRangeExt, Bias, DisplayPoint, Editor, EditorEvent, ExcerptId, ExcerptRange,
+    MultiBufferSnapshot, RangeToAnchorExt, SelectionEffects,
     display_map::ToDisplayPoint,
     items::{entry_git_aware_label_color, entry_label_color},
-    scroll::{Autoscroll, ScrollAnchor, ScrollbarAutoHide},
+    scroll::{Autoscroll, ScrollAnchor},
 };
 use file_icons::FileIcons;
 use fuzzy::{StringMatch, StringMatchCandidate, match_strings};
@@ -45,19 +45,18 @@ use serde::{Deserialize, Serialize};
 use settings::{Settings, SettingsStore};
 use smol::channel;
 use theme::{SyntaxTheme, ThemeSettings};
-use ui::{DynamicSpacing, IndentGuideColors, IndentGuideLayout};
+use ui::{
+    ActiveTheme, ButtonCommon, Clickable, Color, ContextMenu, DynamicSpacing, FluentBuilder,
+    HighlightedLabel, Icon, IconButton, IconButtonShape, IconName, IconSize, IndentGuideColors,
+    IndentGuideLayout, Label, LabelCommon, ListItem, ScrollAxes, Scrollbars, StyledExt,
+    StyledTypography, Toggleable, Tooltip, WithScrollbar, h_flex, v_flex,
+};
 use util::{RangeExt, ResultExt, TryFutureExt, debug_panic};
 use workspace::{
     OpenInTerminal, WeakItemHandle, Workspace,
     dock::{DockPosition, Panel, PanelEvent},
     item::ItemHandle,
     searchable::{SearchEvent, SearchableItem},
-    ui::{
-        ActiveTheme, ButtonCommon, Clickable, Color, ContextMenu, FluentBuilder, HighlightedLabel,
-        Icon, IconButton, IconButtonShape, IconName, IconSize, Label, LabelCommon, ListItem,
-        Scrollbar, ScrollbarState, StyledExt, StyledTypography, Toggleable, Tooltip, h_flex,
-        v_flex,
-    },
 };
 use worktree::{Entry, ProjectEntryId, WorktreeId};
 
@@ -125,10 +124,6 @@ pub struct OutlinePanel {
     cached_entries: Vec<CachedEntry>,
     filter_editor: Entity<Editor>,
     mode: ItemsDisplayMode,
-    show_scrollbar: bool,
-    vertical_scrollbar_state: ScrollbarState,
-    horizontal_scrollbar_state: ScrollbarState,
-    hide_scrollbar_task: Option<Task<()>>,
     max_width_item_index: Option<usize>,
     preserve_selection_on_buffer_fold_toggles: HashSet<BufferId>,
     pending_default_expansion_depth: Option<usize>,
@@ -752,10 +747,6 @@ impl OutlinePanel {
 
             let focus_handle = cx.focus_handle();
             let focus_subscription = cx.on_focus(&focus_handle, window, Self::focus_in);
-            let focus_out_subscription =
-                cx.on_focus_out(&focus_handle, window, |outline_panel, _, window, cx| {
-                    outline_panel.hide_scrollbar(window, cx);
-                });
             let workspace_subscription = cx.subscribe_in(
                 &workspace
                     .weak_handle()
@@ -868,12 +859,6 @@ impl OutlinePanel {
                 workspace: workspace_handle,
                 project,
                 fs: workspace.app_state().fs.clone(),
-                show_scrollbar: !Self::should_autohide_scrollbar(cx),
-                hide_scrollbar_task: None,
-                vertical_scrollbar_state: ScrollbarState::new(scroll_handle.clone())
-                    .parent_entity(&cx.entity()),
-                horizontal_scrollbar_state: ScrollbarState::new(scroll_handle.clone())
-                    .parent_entity(&cx.entity()),
                 max_width_item_index: None,
                 scroll_handle,
                 focus_handle,
@@ -903,7 +888,6 @@ impl OutlinePanel {
                     settings_subscription,
                     icons_subscription,
                     focus_subscription,
-                    focus_out_subscription,
                     workspace_subscription,
                     filter_update_subscription,
                 ],
@@ -4489,150 +4473,6 @@ impl OutlinePanel {
         cx.notify();
     }
 
-    fn render_vertical_scrollbar(&self, cx: &mut Context<Self>) -> Option<Stateful<Div>> {
-        if !Self::should_show_scrollbar(cx)
-            || !(self.show_scrollbar || self.vertical_scrollbar_state.is_dragging())
-        {
-            return None;
-        }
-        Some(
-            div()
-                .occlude()
-                .id("project-panel-vertical-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_mouse_up(
-                    MouseButton::Left,
-                    cx.listener(|outline_panel, _, window, cx| {
-                        if !outline_panel.vertical_scrollbar_state.is_dragging()
-                            && !outline_panel.focus_handle.contains_focused(window, cx)
-                        {
-                            outline_panel.hide_scrollbar(window, cx);
-                            cx.notify();
-                        }
-
-                        cx.stop_propagation();
-                    }),
-                )
-                .on_scroll_wheel(cx.listener(|_, _, _, cx| {
-                    cx.notify();
-                }))
-                .h_full()
-                .absolute()
-                .right_1()
-                .top_1()
-                .bottom_0()
-                .w(px(12.))
-                .cursor_default()
-                .children(Scrollbar::vertical(self.vertical_scrollbar_state.clone())),
-        )
-    }
-
-    fn render_horizontal_scrollbar(
-        &self,
-        _: &mut Window,
-        cx: &mut Context<Self>,
-    ) -> Option<Stateful<Div>> {
-        if !Self::should_show_scrollbar(cx)
-            || !(self.show_scrollbar || self.horizontal_scrollbar_state.is_dragging())
-        {
-            return None;
-        }
-        Scrollbar::horizontal(self.horizontal_scrollbar_state.clone()).map(|scrollbar| {
-            div()
-                .occlude()
-                .id("project-panel-horizontal-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_mouse_up(
-                    MouseButton::Left,
-                    cx.listener(|outline_panel, _, window, cx| {
-                        if !outline_panel.horizontal_scrollbar_state.is_dragging()
-                            && !outline_panel.focus_handle.contains_focused(window, cx)
-                        {
-                            outline_panel.hide_scrollbar(window, cx);
-                            cx.notify();
-                        }
-
-                        cx.stop_propagation();
-                    }),
-                )
-                .on_scroll_wheel(cx.listener(|_, _, _, cx| {
-                    cx.notify();
-                }))
-                .w_full()
-                .absolute()
-                .right_1()
-                .left_1()
-                .bottom_0()
-                .h(px(12.))
-                .cursor_default()
-                .child(scrollbar)
-        })
-    }
-
-    fn should_show_scrollbar(cx: &App) -> bool {
-        let show = OutlinePanelSettings::get_global(cx)
-            .scrollbar
-            .show
-            .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show);
-        match show {
-            ShowScrollbar::Auto => true,
-            ShowScrollbar::System => true,
-            ShowScrollbar::Always => true,
-            ShowScrollbar::Never => false,
-        }
-    }
-
-    fn should_autohide_scrollbar(cx: &App) -> bool {
-        let show = OutlinePanelSettings::get_global(cx)
-            .scrollbar
-            .show
-            .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show);
-        match show {
-            ShowScrollbar::Auto => true,
-            ShowScrollbar::System => cx
-                .try_global::<ScrollbarAutoHide>()
-                .map_or_else(|| cx.should_auto_hide_scrollbars(), |autohide| autohide.0),
-            ShowScrollbar::Always => false,
-            ShowScrollbar::Never => true,
-        }
-    }
-
-    fn hide_scrollbar(&mut self, window: &mut Window, cx: &mut Context<Self>) {
-        const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
-        if !Self::should_autohide_scrollbar(cx) {
-            return;
-        }
-        self.hide_scrollbar_task = Some(cx.spawn_in(window, async move |panel, cx| {
-            cx.background_executor()
-                .timer(SCROLLBAR_SHOW_INTERVAL)
-                .await;
-            panel
-                .update(cx, |panel, cx| {
-                    panel.show_scrollbar = false;
-                    cx.notify();
-                })
-                .log_err();
-        }))
-    }
-
     fn width_estimate(&self, depth: usize, entry: &PanelEntry, cx: &App) -> u64 {
         let item_text_chars = match entry {
             PanelEntry::Fs(FsEntry::ExternalFile(external)) => self
@@ -4688,7 +4528,7 @@ impl OutlinePanel {
         indent_size: f32,
         window: &mut Window,
         cx: &mut Context<Self>,
-    ) -> Div {
+    ) -> impl IntoElement {
         let contents = if self.cached_entries.is_empty() {
             let header = if self.updating_fs_entries || self.updating_cached_entries {
                 None
@@ -4699,6 +4539,7 @@ impl OutlinePanel {
             };
 
             v_flex()
+                .id("empty-outline-state")
                 .flex_1()
                 .justify_center()
                 .size_full()
@@ -4848,10 +4689,13 @@ impl OutlinePanel {
                 .flex_shrink()
                 .size_full()
                 .child(list_contents.size_full().flex_shrink())
-                .children(self.render_vertical_scrollbar(cx))
-                .when_some(
-                    self.render_horizontal_scrollbar(window, cx),
-                    |this, scrollbar| this.pb_4().child(scrollbar),
+                .custom_scrollbars(
+                    Scrollbars::for_settings::<OutlinePanelSettings>()
+                        .tracked_scroll_handle(self.scroll_handle.clone())
+                        .with_track_along(ScrollAxes::Horizontal)
+                        .tracked_entity(cx.entity_id()),
+                    window,
+                    cx,
                 )
         }
         .children(self.context_menu.as_ref().map(|(menu, position, _)| {
@@ -5119,15 +4963,6 @@ impl Render for OutlinePanel {
             .size_full()
             .overflow_hidden()
             .relative()
-            .on_hover(cx.listener(|this, hovered, window, cx| {
-                if *hovered {
-                    this.show_scrollbar = true;
-                    this.hide_scrollbar_task.take();
-                    cx.notify();
-                } else if !this.focus_handle.contains_focused(window, cx) {
-                    this.hide_scrollbar(window, cx);
-                }
-            }))
             .key_context(self.dispatch_context(window, cx))
             .on_action(cx.listener(Self::open_selected_entry))
             .on_action(cx.listener(Self::cancel))

crates/outline_panel/src/outline_panel_settings.rs 🔗

@@ -1,8 +1,9 @@
-use editor::ShowScrollbar;
-use gpui::Pixels;
+use editor::EditorSettings;
+use gpui::{App, Pixels};
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use settings::{Settings, SettingsKey, SettingsSources, SettingsUi};
+use ui::scrollbars::{ScrollbarVisibility, ShowScrollbar};
 
 #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Copy, PartialEq)]
 #[serde(rename_all = "snake_case")]
@@ -116,6 +117,14 @@ pub struct OutlinePanelSettingsContent {
     pub expand_outlines_with_depth: Option<usize>,
 }
 
+impl ScrollbarVisibility for OutlinePanelSettings {
+    fn visibility(&self, cx: &App) -> ShowScrollbar {
+        self.scrollbar
+            .show
+            .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show)
+    }
+}
+
 impl Settings for OutlinePanelSettings {
     type FileContent = OutlinePanelSettingsContent;
 

crates/picker/Cargo.toml 🔗

@@ -23,7 +23,6 @@ menu.workspace = true
 schemars.workspace = true
 serde.workspace = true
 ui.workspace = true
-util.workspace = true
 workspace.workspace = true
 workspace-hack.workspace = true
 

crates/picker/src/picker.rs 🔗

@@ -11,17 +11,17 @@ use editor::{
 use gpui::{
     Action, AnyElement, App, ClickEvent, Context, DismissEvent, Entity, EventEmitter, FocusHandle,
     Focusable, Length, ListSizingBehavior, ListState, MouseButton, MouseUpEvent, Render,
-    ScrollStrategy, Stateful, Task, UniformListScrollHandle, Window, actions, div, list,
-    prelude::*, uniform_list,
+    ScrollStrategy, Task, UniformListScrollHandle, Window, actions, div, list, prelude::*,
+    uniform_list,
 };
 use head::Head;
 use schemars::JsonSchema;
 use serde::Deserialize;
 use std::{ops::Range, sync::Arc, time::Duration};
 use ui::{
-    Color, Divider, Label, ListItem, ListItemSpacing, Scrollbar, ScrollbarState, prelude::*, v_flex,
+    Color, Divider, Label, ListItem, ListItemSpacing, ScrollAxes, Scrollbars, WithScrollbar,
+    prelude::*, v_flex,
 };
-use util::ResultExt;
 use workspace::ModalView;
 
 enum ElementContainer {
@@ -65,13 +65,8 @@ pub struct Picker<D: PickerDelegate> {
     width: Option<Length>,
     widest_item: Option<usize>,
     max_height: Option<Length>,
-    focus_handle: FocusHandle,
     /// An external control to display a scrollbar in the `Picker`.
     show_scrollbar: bool,
-    /// An internal state that controls whether to show the scrollbar based on the user's focus.
-    scrollbar_visibility: bool,
-    scrollbar_state: ScrollbarState,
-    hide_scrollbar_task: Option<Task<()>>,
     /// Whether the `Picker` is rendered as a self-contained modal.
     ///
     /// Set this to `false` when rendering the `Picker` as part of a larger modal.
@@ -293,13 +288,6 @@ impl<D: PickerDelegate> Picker<D> {
         cx: &mut Context<Self>,
     ) -> Self {
         let element_container = Self::create_element_container(container);
-        let scrollbar_state = match &element_container {
-            ElementContainer::UniformList(scroll_handle) => {
-                ScrollbarState::new(scroll_handle.clone())
-            }
-            ElementContainer::List(state) => ScrollbarState::new(state.clone()),
-        };
-        let focus_handle = cx.focus_handle();
         let mut this = Self {
             delegate,
             head,
@@ -309,12 +297,8 @@ impl<D: PickerDelegate> Picker<D> {
             width: None,
             widest_item: None,
             max_height: Some(rems(18.).into()),
-            focus_handle,
             show_scrollbar: false,
-            scrollbar_visibility: true,
-            scrollbar_state,
             is_modal: true,
-            hide_scrollbar_task: None,
         };
         this.update_matches("".to_string(), window, cx);
         // give the delegate 4ms to render the first set of suggestions.
@@ -790,67 +774,6 @@ impl<D: PickerDelegate> Picker<D> {
             }
         }
     }
-
-    fn hide_scrollbar(&mut self, cx: &mut Context<Self>) {
-        const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
-        self.hide_scrollbar_task = Some(cx.spawn(async move |panel, cx| {
-            cx.background_executor()
-                .timer(SCROLLBAR_SHOW_INTERVAL)
-                .await;
-            panel
-                .update(cx, |panel, cx| {
-                    panel.scrollbar_visibility = false;
-                    cx.notify();
-                })
-                .log_err();
-        }))
-    }
-
-    fn render_scrollbar(&self, cx: &mut Context<Self>) -> Option<Stateful<Div>> {
-        if !self.show_scrollbar
-            || !(self.scrollbar_visibility || self.scrollbar_state.is_dragging())
-        {
-            return None;
-        }
-        Some(
-            div()
-                .occlude()
-                .id("picker-scroll")
-                .h_full()
-                .absolute()
-                .right_1()
-                .top_1()
-                .bottom_0()
-                .w(px(12.))
-                .cursor_default()
-                .on_mouse_move(cx.listener(|_, _, _window, cx| {
-                    cx.notify();
-                    cx.stop_propagation()
-                }))
-                .on_hover(|_, _window, cx| {
-                    cx.stop_propagation();
-                })
-                .on_any_mouse_down(|_, _window, cx| {
-                    cx.stop_propagation();
-                })
-                .on_mouse_up(
-                    MouseButton::Left,
-                    cx.listener(|picker, _, window, cx| {
-                        if !picker.scrollbar_state.is_dragging()
-                            && !picker.focus_handle.contains_focused(window, cx)
-                        {
-                            picker.hide_scrollbar(cx);
-                            cx.notify();
-                        }
-                        cx.stop_propagation();
-                    }),
-                )
-                .on_scroll_wheel(cx.listener(|_, _, _window, cx| {
-                    cx.notify();
-                }))
-                .children(Scrollbar::vertical(self.scrollbar_state.clone())),
-        )
-    }
 }
 
 impl<D: PickerDelegate> EventEmitter<DismissEvent> for Picker<D> {}
@@ -900,17 +823,22 @@ impl<D: PickerDelegate> Render for Picker<D> {
                         .overflow_hidden()
                         .children(self.delegate.render_header(window, cx))
                         .child(self.render_element_container(cx))
-                        .on_hover(cx.listener(|this, hovered, window, cx| {
-                            if *hovered {
-                                this.scrollbar_visibility = true;
-                                this.hide_scrollbar_task.take();
-                                cx.notify();
-                            } else if !this.focus_handle.contains_focused(window, cx) {
-                                this.hide_scrollbar(cx);
-                            }
-                        }))
-                        .when_some(self.render_scrollbar(cx), |div, scrollbar| {
-                            div.child(scrollbar)
+                        .when(self.show_scrollbar, |this| {
+                            let base_scrollbar_config =
+                                Scrollbars::new(ScrollAxes::Vertical).width_sm();
+
+                            this.map(|this| match &self.element_container {
+                                ElementContainer::List(state) => this.custom_scrollbars(
+                                    base_scrollbar_config.tracked_scroll_handle(state.clone()),
+                                    window,
+                                    cx,
+                                ),
+                                ElementContainer::UniformList(state) => this.custom_scrollbars(
+                                    base_scrollbar_config.tracked_scroll_handle(state.clone()),
+                                    window,
+                                    cx,
+                                ),
+                            })
                         }),
                 )
             })

crates/project_panel/src/project_panel.rs 🔗

@@ -7,12 +7,11 @@ use collections::{BTreeSet, HashMap, hash_map};
 use command_palette_hooks::CommandPaletteFilter;
 use db::kvp::KEY_VALUE_STORE;
 use editor::{
-    Editor, EditorEvent, EditorSettings, ShowScrollbar,
+    Editor, EditorEvent,
     items::{
         entry_diagnostic_aware_icon_decoration_and_color,
         entry_diagnostic_aware_icon_name_and_color, entry_git_aware_label_color,
     },
-    scroll::ScrollbarAutoHide,
 };
 use file_icons::FileIcons;
 use git::status::GitSummary;
@@ -59,7 +58,8 @@ use theme::ThemeSettings;
 use ui::{
     Color, ContextMenu, DecoratedIcon, Divider, Icon, IconDecoration, IconDecorationKind,
     IndentGuideColors, IndentGuideLayout, KeyBinding, Label, LabelSize, ListItem, ListItemSpacing,
-    ScrollableHandle, Scrollbar, ScrollbarState, StickyCandidate, Tooltip, prelude::*, v_flex,
+    ScrollAxes, ScrollableHandle, Scrollbars, StickyCandidate, Tooltip, WithScrollbar, prelude::*,
+    v_flex,
 };
 use util::{ResultExt, TakeUntilExt, TryFutureExt, maybe, paths::compare_paths};
 use workspace::{
@@ -109,10 +109,6 @@ pub struct ProjectPanel {
     workspace: WeakEntity<Workspace>,
     width: Option<Pixels>,
     pending_serialization: Task<Option<()>>,
-    show_scrollbar: bool,
-    vertical_scrollbar_state: ScrollbarState,
-    horizontal_scrollbar_state: ScrollbarState,
-    hide_scrollbar_task: Option<Task<()>>,
     diagnostics: HashMap<(WorktreeId, PathBuf), DiagnosticSeverity>,
     max_width_item_index: Option<usize>,
     diagnostic_summary_update: Task<()>,
@@ -461,7 +457,6 @@ impl ProjectPanel {
             cx.on_focus(&focus_handle, window, Self::focus_in).detach();
             cx.on_focus_out(&focus_handle, window, |this, _, window, cx| {
                 this.focus_out(window, cx);
-                this.hide_scrollbar(window, cx);
             })
             .detach();
 
@@ -652,12 +647,6 @@ impl ProjectPanel {
                 workspace: workspace.weak_handle(),
                 width: None,
                 pending_serialization: Task::ready(None),
-                show_scrollbar: !Self::should_autohide_scrollbar(cx),
-                hide_scrollbar_task: None,
-                vertical_scrollbar_state: ScrollbarState::new(scroll_handle.clone())
-                    .parent_entity(&cx.entity()),
-                horizontal_scrollbar_state: ScrollbarState::new(scroll_handle.clone())
-                    .parent_entity(&cx.entity()),
                 max_width_item_index: None,
                 diagnostics: Default::default(),
                 diagnostic_summary_update: Task::ready(()),
@@ -4847,103 +4836,6 @@ impl ProjectPanel {
         }
     }
 
-    fn render_vertical_scrollbar(&self, cx: &mut Context<Self>) -> Option<Stateful<Div>> {
-        if !Self::should_show_scrollbar(cx)
-            || !(self.show_scrollbar || self.vertical_scrollbar_state.is_dragging())
-        {
-            return None;
-        }
-        Some(
-            div()
-                .occlude()
-                .id("project-panel-vertical-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_mouse_up(
-                    MouseButton::Left,
-                    cx.listener(|this, _, window, cx| {
-                        if !this.vertical_scrollbar_state.is_dragging()
-                            && !this.focus_handle.contains_focused(window, cx)
-                        {
-                            this.hide_scrollbar(window, cx);
-                            cx.notify();
-                        }
-
-                        cx.stop_propagation();
-                    }),
-                )
-                .on_scroll_wheel(cx.listener(|_, _, _, cx| {
-                    cx.notify();
-                }))
-                .h_full()
-                .absolute()
-                .right_1()
-                .top_1()
-                .bottom_1()
-                .w(px(12.))
-                .cursor_default()
-                .children(Scrollbar::vertical(
-                    // percentage as f32..end_offset as f32,
-                    self.vertical_scrollbar_state.clone(),
-                )),
-        )
-    }
-
-    fn render_horizontal_scrollbar(&self, cx: &mut Context<Self>) -> Option<Stateful<Div>> {
-        if !Self::should_show_scrollbar(cx)
-            || !(self.show_scrollbar || self.horizontal_scrollbar_state.is_dragging())
-        {
-            return None;
-        }
-        Scrollbar::horizontal(self.horizontal_scrollbar_state.clone()).map(|scrollbar| {
-            div()
-                .occlude()
-                .id("project-panel-horizontal-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_mouse_up(
-                    MouseButton::Left,
-                    cx.listener(|this, _, window, cx| {
-                        if !this.horizontal_scrollbar_state.is_dragging()
-                            && !this.focus_handle.contains_focused(window, cx)
-                        {
-                            this.hide_scrollbar(window, cx);
-                            cx.notify();
-                        }
-
-                        cx.stop_propagation();
-                    }),
-                )
-                .on_scroll_wheel(cx.listener(|_, _, _, cx| {
-                    cx.notify();
-                }))
-                .w_full()
-                .absolute()
-                .right_1()
-                .left_1()
-                .bottom_1()
-                .h(px(12.))
-                .cursor_default()
-                .child(scrollbar)
-        })
-    }
-
     fn dispatch_context(&self, window: &Window, cx: &Context<Self>) -> KeyContext {
         let mut dispatch_context = KeyContext::new_with_defaults();
         dispatch_context.add("ProjectPanel");
@@ -4959,52 +4851,6 @@ impl ProjectPanel {
         dispatch_context
     }
 
-    fn should_show_scrollbar(cx: &App) -> bool {
-        let show = ProjectPanelSettings::get_global(cx)
-            .scrollbar
-            .show
-            .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show);
-        match show {
-            ShowScrollbar::Auto => true,
-            ShowScrollbar::System => true,
-            ShowScrollbar::Always => true,
-            ShowScrollbar::Never => false,
-        }
-    }
-
-    fn should_autohide_scrollbar(cx: &App) -> bool {
-        let show = ProjectPanelSettings::get_global(cx)
-            .scrollbar
-            .show
-            .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show);
-        match show {
-            ShowScrollbar::Auto => true,
-            ShowScrollbar::System => cx
-                .try_global::<ScrollbarAutoHide>()
-                .map_or_else(|| cx.should_auto_hide_scrollbars(), |autohide| autohide.0),
-            ShowScrollbar::Always => false,
-            ShowScrollbar::Never => true,
-        }
-    }
-
-    fn hide_scrollbar(&mut self, window: &mut Window, cx: &mut Context<Self>) {
-        const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
-        if !Self::should_autohide_scrollbar(cx) {
-            return;
-        }
-        self.hide_scrollbar_task = Some(cx.spawn_in(window, async move |panel, cx| {
-            cx.background_executor()
-                .timer(SCROLLBAR_SHOW_INTERVAL)
-                .await;
-            panel
-                .update(cx, |panel, cx| {
-                    panel.show_scrollbar = false;
-                    cx.notify();
-                })
-                .log_err();
-        }))
-    }
-
     fn reveal_entry(
         &mut self,
         project: Entity<Project>,
@@ -5357,15 +5203,6 @@ impl Render for ProjectPanel {
                         this.refresh_drag_cursor_style(&event.modifiers, window, cx);
                     },
                 ))
-                .on_hover(cx.listener(|this, hovered, window, cx| {
-                    if *hovered {
-                        this.show_scrollbar = true;
-                        this.hide_scrollbar_task.take();
-                        cx.notify();
-                    } else if !this.focus_handle.contains_focused(window, cx) {
-                        this.hide_scrollbar(window, cx);
-                    }
-                }))
                 .on_click(cx.listener(|this, event, _, cx| {
                     if matches!(event, gpui::ClickEvent::Keyboard(_)) {
                         return;
@@ -5755,10 +5592,14 @@ impl Render for ProjectPanel {
                         )
                         .size_full(),
                 )
-                .children(self.render_vertical_scrollbar(cx))
-                .when_some(self.render_horizontal_scrollbar(cx), |this, scrollbar| {
-                    this.pb_4().child(scrollbar)
-                })
+                .custom_scrollbars(
+                    Scrollbars::for_settings::<ProjectPanelSettings>()
+                        .tracked_scroll_handle(self.scroll_handle.clone())
+                        .with_track_along(ScrollAxes::Horizontal)
+                        .notify_content(),
+                    window,
+                    cx,
+                )
                 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
                     deferred(
                         anchored()

crates/project_panel/src/project_panel_settings.rs 🔗

@@ -1,8 +1,9 @@
-use editor::ShowScrollbar;
+use editor::EditorSettings;
 use gpui::Pixels;
 use schemars::JsonSchema;
 use serde_derive::{Deserialize, Serialize};
 use settings::{Settings, SettingsKey, SettingsSources, SettingsUi};
+use ui::scrollbars::{ScrollbarVisibility, ShowScrollbar};
 
 #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Copy, PartialEq)]
 #[serde(rename_all = "snake_case")]
@@ -168,6 +169,14 @@ pub struct ProjectPanelSettingsContent {
     pub drag_and_drop: Option<bool>,
 }
 
+impl ScrollbarVisibility for ProjectPanelSettings {
+    fn visibility(&self, cx: &ui::App) -> ShowScrollbar {
+        self.scrollbar
+            .show
+            .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show)
+    }
+}
+
 impl Settings for ProjectPanelSettings {
     type FileContent = ProjectPanelSettingsContent;
 

crates/recent_projects/src/remote_servers.rs 🔗

@@ -23,7 +23,6 @@ use remote::{
 use settings::{Settings, SettingsStore, update_settings_file, watch_config_file};
 use smol::stream::StreamExt as _;
 use std::{
-    any::Any,
     borrow::Cow,
     collections::BTreeSet,
     path::PathBuf,
@@ -35,7 +34,7 @@ use std::{
 };
 use ui::{
     IconButtonShape, List, ListItem, ListSeparator, Modal, ModalHeader, Navigable, NavigableEntry,
-    Scrollbar, ScrollbarState, Section, Tooltip, prelude::*,
+    Section, Tooltip, WithScrollbar, prelude::*,
 };
 use util::{
     ResultExt,
@@ -284,7 +283,7 @@ impl RemoteEntry {
 
 #[derive(Clone)]
 struct DefaultState {
-    scrollbar: ScrollbarState,
+    scroll_handle: ScrollHandle,
     add_new_server: NavigableEntry,
     servers: Vec<RemoteEntry>,
 }
@@ -292,7 +291,6 @@ struct DefaultState {
 impl DefaultState {
     fn new(ssh_config_servers: &BTreeSet<SharedString>, cx: &mut App) -> Self {
         let handle = ScrollHandle::new();
-        let scrollbar = ScrollbarState::new(handle.clone());
         let add_new_server = NavigableEntry::new(&handle, cx);
 
         let ssh_settings = SshSettings::get_global(cx);
@@ -333,7 +331,7 @@ impl DefaultState {
         }
 
         Self {
-            scrollbar,
+            scroll_handle: handle,
             add_new_server,
             servers,
         }
@@ -1449,7 +1447,6 @@ impl RemoteServerProjects {
             }
         }
 
-        let scroll_state = state.scrollbar.parent_entity(&cx.entity());
         let connect_button = div()
             .id("ssh-connect-new-server-container")
             .track_focus(&state.add_new_server.focus_handle)
@@ -1480,17 +1477,12 @@ impl RemoteServerProjects {
                 cx.notify();
             }));
 
-        let handle = &**scroll_state.scroll_handle() as &dyn Any;
-        let Some(scroll_handle) = handle.downcast_ref::<ScrollHandle>() else {
-            unreachable!()
-        };
-
         let mut modal_section = Navigable::new(
             v_flex()
                 .track_focus(&self.focus_handle(cx))
                 .id("ssh-server-list")
                 .overflow_y_scroll()
-                .track_scroll(scroll_handle)
+                .track_scroll(&state.scroll_handle)
                 .size_full()
                 .child(connect_button)
                 .child(
@@ -1585,17 +1577,7 @@ impl RemoteServerProjects {
                             )
                             .size_full(),
                         )
-                        .child(
-                            div()
-                                .occlude()
-                                .h_full()
-                                .absolute()
-                                .top_1()
-                                .bottom_1()
-                                .right_1()
-                                .w(px(8.))
-                                .children(Scrollbar::vertical(scroll_state)),
-                        ),
+                        .vertical_scrollbar_for(state.scroll_handle, window, cx),
                 ),
             )
             .into_any_element()

crates/terminal_view/src/terminal_view.rs 🔗

@@ -7,12 +7,11 @@ mod terminal_slash_command;
 pub mod terminal_tab_tooltip;
 
 use assistant_slash_command::SlashCommandRegistry;
-use editor::{EditorSettings, actions::SelectAll, scroll::ScrollbarAutoHide};
+use editor::{EditorSettings, actions::SelectAll};
 use gpui::{
     Action, AnyElement, App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
     KeyContext, KeyDownEvent, Keystroke, MouseButton, MouseDownEvent, Pixels, Render,
-    ScrollWheelEvent, Stateful, Styled, Subscription, Task, WeakEntity, actions, anchored,
-    deferred, div,
+    ScrollWheelEvent, Styled, Subscription, Task, WeakEntity, actions, anchored, deferred, div,
 };
 use persistence::TERMINAL_DB;
 use project::{Project, search::SearchQuery};
@@ -35,7 +34,9 @@ use terminal_scrollbar::TerminalScrollHandle;
 use terminal_slash_command::TerminalSlashCommand;
 use terminal_tab_tooltip::TerminalTooltip;
 use ui::{
-    ContextMenu, Icon, IconName, Label, Scrollbar, ScrollbarState, Tooltip, h_flex, prelude::*,
+    ContextMenu, Icon, IconName, Label, ScrollAxes, Scrollbars, Tooltip, WithScrollbar, h_flex,
+    prelude::*,
+    scrollbars::{self, GlobalSetting, ScrollbarVisibility},
 };
 use util::ResultExt;
 use workspace::{
@@ -68,7 +69,6 @@ struct ImeState {
 }
 
 const CURSOR_BLINK_INTERVAL: Duration = Duration::from_millis(500);
-const TERMINAL_SCROLLBAR_WIDTH: Pixels = px(12.);
 
 /// Event to transmit the scroll from the element to the view
 #[derive(Clone, Debug, PartialEq)]
@@ -139,10 +139,7 @@ pub struct TerminalView {
     show_breadcrumbs: bool,
     block_below_cursor: Option<Rc<BlockProperties>>,
     scroll_top: Pixels,
-    scrollbar_state: ScrollbarState,
     scroll_handle: TerminalScrollHandle,
-    show_scrollbar: bool,
-    hide_scrollbar_task: Option<Task<()>>,
     ime_state: Option<ImeState>,
     _subscriptions: Vec<Subscription>,
     _terminal_subscriptions: Vec<Subscription>,
@@ -262,10 +259,7 @@ impl TerminalView {
             show_breadcrumbs: TerminalSettings::get_global(cx).toolbar.breadcrumbs,
             block_below_cursor: None,
             scroll_top: Pixels::ZERO,
-            scrollbar_state: ScrollbarState::new(scroll_handle.clone()),
             scroll_handle,
-            show_scrollbar: !Self::should_autohide_scrollbar(cx),
-            hide_scrollbar_task: None,
             cwd_serialized: false,
             ime_state: None,
             _subscriptions: vec![
@@ -836,136 +830,6 @@ impl TerminalView {
         self.terminal = terminal;
     }
 
-    // Hack: Using editor in terminal causes cyclic dependency i.e. editor -> terminal -> project -> editor.
-    fn map_show_scrollbar_from_editor_to_terminal(
-        show_scrollbar: editor::ShowScrollbar,
-    ) -> terminal_settings::ShowScrollbar {
-        match show_scrollbar {
-            editor::ShowScrollbar::Auto => terminal_settings::ShowScrollbar::Auto,
-            editor::ShowScrollbar::System => terminal_settings::ShowScrollbar::System,
-            editor::ShowScrollbar::Always => terminal_settings::ShowScrollbar::Always,
-            editor::ShowScrollbar::Never => terminal_settings::ShowScrollbar::Never,
-        }
-    }
-
-    fn should_show_scrollbar(cx: &App) -> bool {
-        let show = TerminalSettings::get_global(cx)
-            .scrollbar
-            .show
-            .unwrap_or_else(|| {
-                Self::map_show_scrollbar_from_editor_to_terminal(
-                    EditorSettings::get_global(cx).scrollbar.show,
-                )
-            });
-        match show {
-            terminal_settings::ShowScrollbar::Auto => true,
-            terminal_settings::ShowScrollbar::System => true,
-            terminal_settings::ShowScrollbar::Always => true,
-            terminal_settings::ShowScrollbar::Never => false,
-        }
-    }
-
-    fn should_autohide_scrollbar(cx: &App) -> bool {
-        let show = TerminalSettings::get_global(cx)
-            .scrollbar
-            .show
-            .unwrap_or_else(|| {
-                Self::map_show_scrollbar_from_editor_to_terminal(
-                    EditorSettings::get_global(cx).scrollbar.show,
-                )
-            });
-        match show {
-            terminal_settings::ShowScrollbar::Auto => true,
-            terminal_settings::ShowScrollbar::System => cx
-                .try_global::<ScrollbarAutoHide>()
-                .map_or_else(|| cx.should_auto_hide_scrollbars(), |autohide| autohide.0),
-            terminal_settings::ShowScrollbar::Always => false,
-            terminal_settings::ShowScrollbar::Never => true,
-        }
-    }
-
-    fn hide_scrollbar(&mut self, cx: &mut Context<Self>) {
-        const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
-        if !Self::should_autohide_scrollbar(cx) {
-            return;
-        }
-        self.hide_scrollbar_task = Some(cx.spawn(async move |panel, cx| {
-            cx.background_executor()
-                .timer(SCROLLBAR_SHOW_INTERVAL)
-                .await;
-            panel
-                .update(cx, |panel, cx| {
-                    panel.show_scrollbar = false;
-                    cx.notify();
-                })
-                .log_err();
-        }))
-    }
-
-    fn render_scrollbar(&self, window: &Window, cx: &mut Context<Self>) -> Option<Stateful<Div>> {
-        if !Self::should_show_scrollbar(cx)
-            || !(self.show_scrollbar || self.scrollbar_state.is_dragging())
-            || !self.content_mode(window, cx).is_scrollable()
-        {
-            return None;
-        }
-
-        if self.terminal.read(cx).total_lines() == self.terminal.read(cx).viewport_lines() {
-            return None;
-        }
-
-        self.scroll_handle.update(self.terminal.read(cx));
-
-        if let Some(new_display_offset) = self.scroll_handle.future_display_offset.take() {
-            self.terminal.update(cx, |term, _| {
-                let delta = new_display_offset as i32 - term.last_content.display_offset as i32;
-                match delta.cmp(&0) {
-                    std::cmp::Ordering::Greater => term.scroll_up_by(delta as usize),
-                    std::cmp::Ordering::Less => term.scroll_down_by(-delta as usize),
-                    std::cmp::Ordering::Equal => {}
-                }
-            });
-        }
-
-        Some(
-            div()
-                .occlude()
-                .id("terminal-view-scroll")
-                .on_mouse_move(cx.listener(|_, _, _window, cx| {
-                    cx.notify();
-                    cx.stop_propagation()
-                }))
-                .on_hover(|_, _window, cx| {
-                    cx.stop_propagation();
-                })
-                .on_any_mouse_down(|_, _window, cx| {
-                    cx.stop_propagation();
-                })
-                .on_mouse_up(
-                    MouseButton::Left,
-                    cx.listener(|terminal_view, _, window, cx| {
-                        if !terminal_view.scrollbar_state.is_dragging()
-                            && !terminal_view.focus_handle.contains_focused(window, cx)
-                        {
-                            terminal_view.hide_scrollbar(cx);
-                            cx.notify();
-                        }
-                        cx.stop_propagation();
-                    }),
-                )
-                .on_scroll_wheel(cx.listener(|_, _, _window, cx| {
-                    cx.notify();
-                }))
-                .absolute()
-                .top_0()
-                .bottom_0()
-                .right_0()
-                .h_full()
-                .w(TERMINAL_SCROLLBAR_WIDTH)
-                .children(Scrollbar::vertical(self.scrollbar_state.clone())),
-        )
-    }
-
     fn rerun_button(task: &TaskState) -> Option<IconButton> {
         if !task.show_rerun {
             return None;
@@ -1120,6 +984,29 @@ fn regex_search_for_query(query: &project::search::SearchQuery) -> Option<RegexS
     }
 }
 
+struct TerminalScrollbarSettingsWrapper;
+
+impl GlobalSetting for TerminalScrollbarSettingsWrapper {
+    fn get_value(_cx: &App) -> &Self {
+        &Self
+    }
+}
+
+impl ScrollbarVisibility for TerminalScrollbarSettingsWrapper {
+    fn visibility(&self, cx: &App) -> scrollbars::ShowScrollbar {
+        TerminalSettings::get_global(cx)
+            .scrollbar
+            .show
+            .map(|value| match value {
+                terminal_settings::ShowScrollbar::Auto => scrollbars::ShowScrollbar::Auto,
+                terminal_settings::ShowScrollbar::System => scrollbars::ShowScrollbar::System,
+                terminal_settings::ShowScrollbar::Always => scrollbars::ShowScrollbar::Always,
+                terminal_settings::ShowScrollbar::Never => scrollbars::ShowScrollbar::Never,
+            })
+            .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show)
+    }
+}
+
 impl TerminalView {
     fn key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
         self.clear_bell(cx);
@@ -1151,28 +1038,31 @@ impl TerminalView {
             terminal.focus_out();
             terminal.set_cursor_shape(CursorShape::Hollow);
         });
-        self.hide_scrollbar(cx);
         cx.notify();
     }
 }
 
 impl Render for TerminalView {
     fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        // TODO: this should be moved out of render
+        self.scroll_handle.update(self.terminal.read(cx));
+
+        if let Some(new_display_offset) = self.scroll_handle.future_display_offset.take() {
+            self.terminal.update(cx, |term, _| {
+                let delta = new_display_offset as i32 - term.last_content.display_offset as i32;
+                match delta.cmp(&0) {
+                    std::cmp::Ordering::Greater => term.scroll_up_by(delta as usize),
+                    std::cmp::Ordering::Less => term.scroll_down_by(-delta as usize),
+                    std::cmp::Ordering::Equal => {}
+                }
+            });
+        }
+
         let terminal_handle = self.terminal.clone();
         let terminal_view_handle = cx.entity();
 
         let focused = self.focus_handle.is_focused(window);
 
-        // Always calculate scrollbar width to prevent layout shift
-        let scrollbar_width = if Self::should_show_scrollbar(cx)
-            && self.content_mode(window, cx).is_scrollable()
-            && self.terminal.read(cx).total_lines() > self.terminal.read(cx).viewport_lines()
-        {
-            TERMINAL_SCROLLBAR_WIDTH
-        } else {
-            px(0.)
-        };
-
         div()
             .id("terminal-view")
             .size_full()
@@ -1209,21 +1099,12 @@ impl Render for TerminalView {
                     }
                 }),
             )
-            .on_hover(cx.listener(|this, hovered, window, cx| {
-                if *hovered {
-                    this.show_scrollbar = true;
-                    this.hide_scrollbar_task.take();
-                    cx.notify();
-                } else if !this.focus_handle.contains_focused(window, cx) {
-                    this.hide_scrollbar(cx);
-                }
-            }))
             .child(
                 // TODO: Oddly this wrapper div is needed for TerminalElement to not steal events from the context menu
                 div()
+                    .id("terminal-view-container")
                     .size_full()
                     .bg(cx.theme().colors().editor_background)
-                    .when(scrollbar_width > px(0.), |div| div.pr(scrollbar_width))
                     .child(TerminalElement::new(
                         terminal_handle,
                         terminal_view_handle,
@@ -1234,8 +1115,15 @@ impl Render for TerminalView {
                         self.block_below_cursor.clone(),
                         self.mode.clone(),
                     ))
-                    .when_some(self.render_scrollbar(window, cx), |div, scrollbar| {
-                        div.child(scrollbar)
+                    .when(self.content_mode(window, cx).is_scrollable(), |div| {
+                        div.custom_scrollbars(
+                            Scrollbars::for_settings::<TerminalScrollbarSettingsWrapper>()
+                                .show_along(ScrollAxes::Vertical)
+                                .with_track_along(ScrollAxes::Vertical)
+                                .tracked_scroll_handle(self.scroll_handle.clone()),
+                            window,
+                            cx,
+                        )
                     }),
             )
             .children(self.context_menu.as_ref().map(|(menu, position, _)| {

crates/ui/Cargo.toml 🔗

@@ -21,6 +21,7 @@ gpui_macros.workspace = true
 icons.workspace = true
 itertools.workspace = true
 menu.workspace = true
+schemars.workspace = true
 serde.workspace = true
 settings.workspace = true
 smallvec.workspace = true

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

@@ -1,39 +1,783 @@
-use std::{
-    any::Any,
-    cell::{Cell, RefCell},
-    fmt::Debug,
-    ops::Range,
-    rc::Rc,
-    sync::Arc,
-    time::Duration,
-};
+use std::{any::Any, fmt::Debug, marker::PhantomData, ops::Not, time::Duration};
 
-use crate::{IntoElement, prelude::*, px, relative};
 use gpui::{
-    Along, App, Axis as ScrollbarAxis, BorderStyle, Bounds, ContentMask, Corners, CursorStyle,
-    Edges, Element, ElementId, Entity, EntityId, GlobalElementId, Hitbox, HitboxBehavior, Hsla,
-    IsZero, LayoutId, ListState, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels,
-    Point, ScrollHandle, ScrollWheelEvent, Size, Style, Task, UniformListScrollHandle, Window,
-    quad,
+    Along, App, AppContext as _, Axis as ScrollbarAxis, BorderStyle, Bounds, ContentMask, Context,
+    Corner, Corners, CursorStyle, Div, Edges, Element, ElementId, Entity, EntityId,
+    GlobalElementId, Hitbox, HitboxBehavior, Hsla, InteractiveElement, IntoElement, IsZero,
+    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,
 };
+use settings::SettingsStore;
+use smallvec::SmallVec;
+use theme::ActiveTheme as _;
+use util::ResultExt;
+
+use std::ops::Range;
+
+use crate::scrollbars::{ScrollbarVisibility, ShowScrollbar};
+
+const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_millis(1500);
+const SCROLLBAR_PADDING: Pixels = px(4.);
+
+pub mod scrollbars {
+    use gpui::{App, Global};
+    use schemars::JsonSchema;
+    use serde::{Deserialize, Serialize};
+    use settings::Settings;
+
+    /// When to show the scrollbar in the editor.
+    ///
+    /// Default: auto
+    #[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
+    #[serde(rename_all = "snake_case")]
+    pub enum ShowScrollbar {
+        /// Show the scrollbar if there's important information or
+        /// follow the system's configured behavior.
+        Auto,
+        /// Match the system's configured behavior.
+        System,
+        /// Always show the scrollbar.
+        Always,
+        /// Never show the scrollbar.
+        Never,
+    }
+
+    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;
+    }
+
+    impl<T: Settings> GlobalSetting for T {
+        fn get_value(cx: &App) -> &T {
+            T::get_global(cx)
+        }
+    }
+
+    impl GlobalSetting for ShowScrollbar {
+        fn get_value(_cx: &App) -> &Self {
+            &ShowScrollbar::Always
+        }
+    }
+
+    pub trait ScrollbarVisibility: GlobalSetting + 'static {
+        fn visibility(&self, cx: &App) -> ShowScrollbar;
+    }
+
+    impl ScrollbarVisibility for ShowScrollbar {
+        fn visibility(&self, cx: &App) -> ShowScrollbar {
+            *ShowScrollbar::get_value(cx)
+        }
+    }
+
+    #[derive(Default)]
+    pub struct ScrollbarAutoHide(pub bool);
+
+    impl ScrollbarAutoHide {
+        pub fn should_hide(&self) -> bool {
+            self.0
+        }
+    }
+
+    impl Global for ScrollbarAutoHide {}
+}
+
+fn get_scrollbar_state<S, T>(
+    mut config: Scrollbars<S, T>,
+    caller_location: &'static std::panic::Location,
+    window: &mut Window,
+    cx: &mut App,
+) -> Entity<ScrollbarStateWrapper<S, T>>
+where
+    S: ScrollbarVisibility,
+    T: ScrollableHandle,
+{
+    let element_id = config.id.take().unwrap_or_else(|| caller_location.into());
+
+    window.use_keyed_state(element_id, cx, |window, cx| {
+        let parent_id = cx.entity_id();
+        ScrollbarStateWrapper(
+            cx.new(|cx| ScrollbarState::new_from_config(config, parent_id, window, cx)),
+        )
+    })
+}
+
+pub trait WithScrollbar: Sized {
+    type Output;
+
+    fn custom_scrollbars<S, T>(
+        self,
+        config: Scrollbars<S, T>,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> Self::Output
+    where
+        S: ScrollbarVisibility,
+        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,
+        )
+    }
+
+    #[track_caller]
+    fn vertical_scrollbar_for<ScrollHandle: ScrollableHandle>(
+        self,
+        scroll_handle: ScrollHandle,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> Self::Output {
+        self.custom_scrollbars(
+            Scrollbars::new(ScrollAxes::Vertical)
+                .tracked_scroll_handle(scroll_handle)
+                .ensure_id(core::panic::Location::caller()),
+            window,
+            cx,
+        )
+    }
+}
+
+impl WithScrollbar for Stateful<Div> {
+    type Output = Self;
+
+    #[track_caller]
+    fn custom_scrollbars<S, T>(
+        self,
+        config: Scrollbars<S, T>,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> Self::Output
+    where
+        S: ScrollbarVisibility,
+        T: ScrollableHandle,
+    {
+        render_scrollbar(
+            get_scrollbar_state(config, std::panic::Location::caller(), window, cx),
+            self,
+            cx,
+        )
+    }
+}
+
+impl WithScrollbar for Div {
+    type Output = Stateful<Div>;
+
+    #[track_caller]
+    fn custom_scrollbars<S, T>(
+        self,
+        config: Scrollbars<S, T>,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> Self::Output
+    where
+        S: ScrollbarVisibility,
+        T: ScrollableHandle,
+    {
+        let scrollbar = get_scrollbar_state(config, std::panic::Location::caller(), window, cx);
+        // We know this ID stays consistent as long as the element is rendered for
+        // consecutive frames, which is sufficient for our use case here
+        let scrollbar_entity_id = scrollbar.entity_id();
+
+        render_scrollbar(
+            scrollbar,
+            self.id(("track-scroll", scrollbar_entity_id)),
+            cx,
+        )
+    }
+}
+
+fn render_scrollbar<S, T>(
+    scrollbar: Entity<ScrollbarStateWrapper<S, T>>,
+    div: Stateful<Div>,
+    cx: &App,
+) -> Stateful<Div>
+where
+    S: ScrollbarVisibility,
+    T: ScrollableHandle,
+{
+    let state = &scrollbar.read(cx).0;
+
+    div.when_some(state.read(cx).handle_to_track(), |this, handle| {
+        this.track_scroll(handle).when_some(
+            state.read(cx).visible_axes(),
+            |this, axes| match axes {
+                ScrollAxes::Horizontal => this.overflow_x_scroll(),
+                ScrollAxes::Vertical => this.overflow_y_scroll(),
+                ScrollAxes::Both => this.overflow_scroll(),
+            },
+        )
+    })
+    .when_some(
+        state
+            .read(cx)
+            .space_to_reserve_for(ScrollbarAxis::Horizontal),
+        |this, space| this.pb(space),
+    )
+    .when_some(
+        state.read(cx).space_to_reserve_for(ScrollbarAxis::Vertical),
+        |this, space| this.pr(space),
+    )
+    .child(state.clone())
+}
+
+impl<S: ScrollbarVisibility, T: ScrollableHandle> UniformListDecoration
+    for ScrollbarStateWrapper<S, T>
+{
+    fn compute(
+        &self,
+        _visible_range: Range<usize>,
+        _bounds: Bounds<Pixels>,
+        scroll_offset: Point<Pixels>,
+        _item_height: Pixels,
+        _item_count: usize,
+        _window: &mut Window,
+        _cx: &mut App,
+    ) -> gpui::AnyElement {
+        ScrollbarElement {
+            origin: scroll_offset.negate(),
+            state: self.0.clone(),
+        }
+        .into_any()
+    }
+}
+
+// impl WithScrollbar for UniformList {
+//     type Output = Self;
+
+//     #[track_caller]
+//     fn custom_scrollbars<S, T>(
+//         self,
+//         config: Scrollbars<S, T>,
+//         window: &mut Window,
+//         cx: &mut App,
+//     ) -> Self::Output
+//     where
+//         S: ScrollbarVisibilitySetting,
+//         T: ScrollableHandle,
+//     {
+//         let scrollbar = get_scrollbar_state(config, std::panic::Location::caller(), window, cx);
+//         self.when_some(
+//             scrollbar.read_with(cx, |wrapper, cx| {
+//                 wrapper
+//                     .0
+//                     .read(cx)
+//                     .handle_to_track::<UniformListScrollHandle>()
+//                     .cloned()
+//             }),
+//             |this, handle| this.track_scroll(handle),
+//         )
+//         .with_decoration(scrollbar)
+//     }
+// }
+
+pub enum ScrollAxes {
+    Horizontal,
+    Vertical,
+    Both,
+}
+
+impl ScrollAxes {
+    fn apply_to<T>(self, point: Point<T>, value: T) -> Point<T>
+    where
+        T: Debug + Default + PartialEq + Clone,
+    {
+        match self {
+            Self::Horizontal => point.apply_along(ScrollbarAxis::Horizontal, |_| value),
+            Self::Vertical => point.apply_along(ScrollbarAxis::Vertical, |_| value),
+            Self::Both => Point::new(value.clone(), value),
+        }
+    }
+}
+
+#[derive(Clone, Debug, Default, PartialEq)]
+enum ReservedSpace {
+    #[default]
+    None,
+    Thumb,
+    Track,
+}
+
+impl ReservedSpace {
+    fn is_visible(&self) -> bool {
+        *self != ReservedSpace::None
+    }
+
+    fn needs_scroll_track(&self) -> bool {
+        *self == ReservedSpace::Track
+    }
+}
+
+#[derive(Debug, Default)]
+enum ScrollbarWidth {
+    #[default]
+    Normal,
+    Small,
+    XSmall,
+}
+
+impl ScrollbarWidth {
+    fn to_pixels(&self) -> Pixels {
+        match self {
+            ScrollbarWidth::Normal => px(8.),
+            ScrollbarWidth::Small => px(6.),
+            ScrollbarWidth::XSmall => px(4.),
+        }
+    }
+}
+
+pub struct Scrollbars<S: ScrollbarVisibility = ShowScrollbar, T: ScrollableHandle = ScrollHandle> {
+    id: Option<ElementId>,
+    tracked_setting: PhantomData<S>,
+    tracked_entity: Option<Option<EntityId>>,
+    scrollable_handle: Box<dyn FnOnce() -> T>,
+    handle_was_added: bool,
+    visibility: Point<ReservedSpace>,
+    scrollbar_width: ScrollbarWidth,
+}
+
+impl Scrollbars {
+    pub fn new(show_along: ScrollAxes) -> Self {
+        Self::new_with_setting(show_along)
+    }
+
+    pub fn for_settings<S: ScrollbarVisibility>() -> Scrollbars<S> {
+        Scrollbars::<S>::new_with_setting(ScrollAxes::Both)
+    }
+}
+
+impl<S: ScrollbarVisibility> Scrollbars<S> {
+    fn new_with_setting(show_along: ScrollAxes) -> Self {
+        Self {
+            id: None,
+            tracked_setting: PhantomData,
+            handle_was_added: false,
+            scrollable_handle: Box::new(ScrollHandle::new),
+            tracked_entity: None,
+            visibility: show_along.apply_to(Default::default(), ReservedSpace::Thumb),
+            scrollbar_width: ScrollbarWidth::Normal,
+        }
+    }
+}
+
+impl<Setting: ScrollbarVisibility, ScrollHandle: ScrollableHandle>
+    Scrollbars<Setting, ScrollHandle>
+{
+    pub fn id(mut self, id: impl Into<ElementId>) -> Self {
+        self.id = Some(id.into());
+        self
+    }
+
+    fn ensure_id(mut self, id: impl Into<ElementId>) -> Self {
+        if self.id.is_none() {
+            self.id = Some(id.into());
+        }
+        self
+    }
+
+    /// Notify the current context whenever this scrollbar gets a scroll event
+    pub fn notify_content(mut self) -> Self {
+        self.tracked_entity = Some(None);
+        self
+    }
+
+    /// Set a parent model which should be notified whenever this scrollbar gets a scroll event.
+    pub fn tracked_entity(mut self, entity_id: EntityId) -> Self {
+        self.tracked_entity = Some(Some(entity_id));
+        self
+    }
+
+    pub fn tracked_scroll_handle<TrackedHandle: ScrollableHandle>(
+        self,
+        tracked_scroll_handle: TrackedHandle,
+    ) -> Scrollbars<Setting, TrackedHandle> {
+        let Self {
+            id,
+            tracked_setting,
+            tracked_entity: tracked_entity_id,
+            scrollbar_width,
+            visibility,
+            ..
+        } = self;
+
+        Scrollbars {
+            handle_was_added: true,
+            scrollable_handle: Box::new(|| tracked_scroll_handle),
+            id,
+            tracked_setting,
+            tracked_entity: tracked_entity_id,
+            visibility,
+            scrollbar_width,
+        }
+    }
+
+    pub fn show_along(mut self, along: ScrollAxes) -> Self {
+        self.visibility = along.apply_to(self.visibility, ReservedSpace::Thumb);
+        self
+    }
+
+    pub fn with_track_along(mut self, along: ScrollAxes) -> Self {
+        self.visibility = along.apply_to(self.visibility, ReservedSpace::Track);
+        self
+    }
+
+    pub fn width_sm(mut self) -> Self {
+        self.scrollbar_width = ScrollbarWidth::Small;
+        self
+    }
+
+    pub fn width_xs(mut self) -> Self {
+        self.scrollbar_width = ScrollbarWidth::XSmall;
+        self
+    }
+}
+
+#[derive(PartialEq, Eq)]
+enum VisibilityState {
+    Visible,
+    Hidden,
+    Disabled,
+}
+
+impl VisibilityState {
+    fn from_show_setting(show_setting: ShowScrollbar) -> Self {
+        if show_setting.show() {
+            Self::Visible
+        } else {
+            Self::Disabled
+        }
+    }
+
+    fn is_visible(&self) -> bool {
+        *self == VisibilityState::Visible
+    }
+
+    #[inline]
+    fn is_disabled(&self) -> bool {
+        *self == VisibilityState::Disabled
+    }
+}
+
+enum ParentHovered {
+    Yes(bool),
+    No(bool),
+}
+
+/// This is used to ensure notifies within the state do not notify the parent
+/// unintentionally.
+struct ScrollbarStateWrapper<S: ScrollbarVisibility, T: ScrollableHandle>(
+    Entity<ScrollbarState<S, T>>,
+);
+
+/// A scrollbar state that should be persisted across frames.
+struct ScrollbarState<S: ScrollbarVisibility, T: ScrollableHandle = ScrollHandle> {
+    thumb_state: ThumbState,
+    notify_id: Option<EntityId>,
+    manually_added: bool,
+    scroll_handle: T,
+    width: ScrollbarWidth,
+    tracked_setting: PhantomData<S>,
+    show_setting: ShowScrollbar,
+    visibility: Point<ReservedSpace>,
+    show_state: VisibilityState,
+    mouse_in_parent: bool,
+    last_prepaint_state: Option<ScrollbarPrepaintState>,
+    _auto_hide_task: Option<Task<()>>,
+}
+
+impl<S: ScrollbarVisibility, T: ScrollableHandle> ScrollbarState<S, T> {
+    fn new_from_config(
+        config: Scrollbars<S, T>,
+        parent_id: EntityId,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Self {
+        cx.observe_global_in::<SettingsStore>(window, Self::settings_changed)
+            .detach();
+
+        ScrollbarState {
+            thumb_state: Default::default(),
+            notify_id: config.tracked_entity.map(|id| id.unwrap_or(parent_id)),
+            manually_added: config.handle_was_added,
+            scroll_handle: (config.scrollable_handle)(),
+            width: config.scrollbar_width,
+            visibility: config.visibility,
+            tracked_setting: PhantomData,
+            show_setting: S::get_value(cx).visibility(cx),
+            show_state: VisibilityState::Visible,
+            mouse_in_parent: true,
+            last_prepaint_state: None,
+            _auto_hide_task: None,
+        }
+    }
+
+    fn settings_changed(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        self.set_show_scrollbar(S::get_value(cx).visibility(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(|| {
+                    cx.spawn_in(window, async move |scrollbar_state, cx| {
+                        cx.background_executor()
+                            .timer(SCROLLBAR_SHOW_INTERVAL)
+                            .await;
+                        scrollbar_state
+                            .update(cx, |state, cx| {
+                                state.set_visibility(VisibilityState::Hidden, cx);
+                                state._auto_hide_task.take()
+                            })
+                            .log_err();
+                    })
+                });
+        }
+    }
+
+    fn show_scrollbars(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        self.set_visibility(VisibilityState::Visible, cx);
+        self._auto_hide_task.take();
+        self.schedule_auto_hide(window, cx);
+    }
+
+    fn set_show_scrollbar(
+        &mut self,
+        show: ShowScrollbar,
+        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);
+            self.schedule_auto_hide(window, cx);
+            cx.notify();
+        }
+    }
+
+    fn set_visibility(&mut self, visibility: VisibilityState, cx: &mut Context<Self>) {
+        if self.show_state != visibility {
+            self.show_state = visibility;
+            cx.notify();
+        }
+    }
+
+    #[inline]
+    fn visible_axes(&self) -> Option<ScrollAxes> {
+        match (&self.visibility.x, &self.visibility.y) {
+            (ReservedSpace::None, ReservedSpace::None) => None,
+            (ReservedSpace::None, _) => Some(ScrollAxes::Vertical),
+            (_, ReservedSpace::None) => Some(ScrollAxes::Horizontal),
+            _ => Some(ScrollAxes::Both),
+        }
+    }
+
+    fn space_to_reserve_for(&self, axis: ScrollbarAxis) -> Option<Pixels> {
+        (self.show_state.is_disabled().not() && self.visibility.along(axis).needs_scroll_track())
+            .then(|| self.space_to_reserve())
+    }
+
+    fn space_to_reserve(&self) -> Pixels {
+        self.width.to_pixels() + 2 * SCROLLBAR_PADDING
+    }
+
+    fn handle_to_track<Handle: ScrollableHandle>(&self) -> Option<&Handle> {
+        (!self.manually_added)
+            .then(|| (self.scroll_handle() as &dyn Any).downcast_ref::<Handle>())
+            .flatten()
+    }
+
+    fn scroll_handle(&self) -> &T {
+        &self.scroll_handle
+    }
+
+    fn set_offset(&mut self, offset: Point<Pixels>, cx: &mut Context<Self>) {
+        self.scroll_handle.set_offset(offset);
+        self.notify_parent(cx);
+        cx.notify();
+    }
+
+    fn is_dragging(&self) -> bool {
+        self.thumb_state.is_dragging()
+    }
+
+    fn set_dragging(
+        &mut self,
+        axis: ScrollbarAxis,
+        drag_offset: Pixels,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.set_thumb_state(ThumbState::Dragging(axis, drag_offset), window, cx);
+        self.scroll_handle().drag_started();
+    }
+
+    fn update_hovered_thumb(
+        &mut self,
+        position: &Point<Pixels>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.set_thumb_state(
+            if let Some(&ScrollbarLayout { axis, .. }) = self
+                .last_prepaint_state
+                .as_ref()
+                .and_then(|state| state.thumb_for_position(position))
+            {
+                ThumbState::Hover(axis)
+            } else {
+                ThumbState::Inactive
+            },
+            window,
+            cx,
+        );
+    }
+
+    fn set_thumb_state(&mut self, state: ThumbState, window: &mut Window, cx: &mut Context<Self>) {
+        if self.thumb_state != state {
+            if state == ThumbState::Inactive {
+                self.schedule_auto_hide(window, cx);
+            } else {
+                self.show_scrollbars(window, cx);
+            }
+            self.thumb_state = state;
+            cx.notify();
+        }
+    }
+
+    fn update_parent_hovered(&mut self, position: &Point<Pixels>) -> ParentHovered {
+        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),
+        }
+    }
+
+    fn parent_hovered(&self, position: &Point<Pixels>) -> bool {
+        self.last_prepaint_state
+            .as_ref()
+            .is_some_and(|state| state.parent_bounds.contains(position))
+    }
+
+    fn hit_for_position(&self, position: &Point<Pixels>) -> Option<&ScrollbarLayout> {
+        self.last_prepaint_state
+            .as_ref()
+            .and_then(|state| state.hit_for_position(position))
+    }
+
+    fn thumb_for_axis(&self, axis: ScrollbarAxis) -> Option<&ScrollbarLayout> {
+        self.last_prepaint_state
+            .as_ref()
+            .and_then(|state| state.thumbs.iter().find(|thumb| thumb.axis == axis))
+    }
+
+    fn thumb_ranges(
+        &self,
+    ) -> impl Iterator<Item = (ScrollbarAxis, Range<f32>, ReservedSpace)> + '_ {
+        const MINIMUM_THUMB_SIZE: Pixels = px(25.);
+        let max_offset = self.scroll_handle().max_offset();
+        let viewport_size = self.scroll_handle().viewport().size;
+        let current_offset = self.scroll_handle().offset();
+
+        [ScrollbarAxis::Horizontal, ScrollbarAxis::Vertical]
+            .into_iter()
+            .filter(|&axis| self.visibility.along(axis).is_visible())
+            .flat_map(move |axis| {
+                let max_offset = max_offset.along(axis);
+                let viewport_size = viewport_size.along(axis);
+                if max_offset.is_zero() || viewport_size.is_zero() {
+                    return None;
+                }
+                let content_size = viewport_size + max_offset;
+                let visible_percentage = viewport_size / content_size;
+                let thumb_size = MINIMUM_THUMB_SIZE.max(viewport_size * visible_percentage);
+                if thumb_size > viewport_size {
+                    return None;
+                }
+                let current_offset = current_offset
+                    .along(axis)
+                    .clamp(-max_offset, Pixels::ZERO)
+                    .abs();
+                let start_offset = (current_offset / max_offset) * (viewport_size - thumb_size);
+                let thumb_percentage_start = start_offset / viewport_size;
+                let thumb_percentage_end = (start_offset + thumb_size) / viewport_size;
+                Some((
+                    axis,
+                    thumb_percentage_start..thumb_percentage_end,
+                    self.visibility.along(axis),
+                ))
+            })
+    }
+
+    fn visible(&self) -> bool {
+        self.show_state.is_visible()
+    }
 
-pub struct Scrollbar {
-    thumb: Range<f32>,
-    state: ScrollbarState,
-    kind: ScrollbarAxis,
+    #[inline]
+    fn disabled(&self) -> bool {
+        self.show_state.is_disabled()
+    }
+
+    fn notify_parent(&self, cx: &mut App) {
+        if let Some(entity_id) = self.notify_id {
+            cx.notify(entity_id);
+        }
+    }
 }
 
-#[derive(Default, Debug, Clone, Copy)]
+impl<S: ScrollbarVisibility, T: ScrollableHandle> Render for ScrollbarState<S, T> {
+    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        ScrollbarElement {
+            state: cx.entity(),
+            origin: Default::default(),
+        }
+    }
+}
+
+struct ScrollbarElement<S: ScrollbarVisibility, T: ScrollableHandle> {
+    origin: Point<Pixels>,
+    state: Entity<ScrollbarState<S, T>>,
+}
+
+#[derive(Default, Debug, PartialEq, Eq)]
 enum ThumbState {
     #[default]
     Inactive,
-    Hover,
-    Dragging(Pixels),
+    Hover(ScrollbarAxis),
+    Dragging(ScrollbarAxis, Pixels),
 }
 
 impl ThumbState {
     fn is_dragging(&self) -> bool {
-        matches!(*self, ThumbState::Dragging(_))
+        matches!(*self, ThumbState::Dragging(..))
     }
 }
 
@@ -99,170 +843,110 @@ impl ScrollableHandle for ScrollHandle {
     }
 }
 
-pub trait ScrollableHandle: Any + Debug {
-    fn content_size(&self) -> Size<Pixels> {
-        self.viewport().size + self.max_offset()
-    }
+pub trait ScrollableHandle: 'static + Any + Sized {
     fn max_offset(&self) -> Size<Pixels>;
     fn set_offset(&self, point: Point<Pixels>);
     fn offset(&self) -> Point<Pixels>;
     fn viewport(&self) -> Bounds<Pixels>;
     fn drag_started(&self) {}
     fn drag_ended(&self) {}
-}
 
-/// A scrollbar state that should be persisted across frames.
-#[derive(Clone, Debug)]
-pub struct ScrollbarState {
-    thumb_state: Rc<Cell<ThumbState>>,
-    parent_id: Option<EntityId>,
-    scroll_handle: Arc<dyn ScrollableHandle>,
-    auto_hide: Rc<RefCell<AutoHide>>,
+    fn scrollable_along(&self, axis: ScrollbarAxis) -> bool {
+        self.max_offset().along(axis) > Pixels::ZERO
+    }
+    fn content_size(&self) -> Size<Pixels> {
+        self.viewport().size + self.max_offset()
+    }
 }
 
-#[derive(Debug)]
-enum AutoHide {
-    Disabled,
-    Hidden {
-        parent_id: EntityId,
-    },
-    Visible {
-        parent_id: EntityId,
-        _task: Task<()>,
-    },
+enum ScrollbarMouseEvent {
+    TrackClick,
+    ThumbDrag(Pixels),
 }
 
-impl AutoHide {
-    fn is_hidden(&self) -> bool {
-        matches!(self, AutoHide::Hidden { .. })
-    }
+struct ScrollbarLayout {
+    thumb_bounds: Bounds<Pixels>,
+    track_bounds: Bounds<Pixels>,
+    cursor_hitbox: Hitbox,
+    reserved_space: ReservedSpace,
+    axis: ScrollbarAxis,
 }
 
-impl ScrollbarState {
-    pub fn new(scroll: impl ScrollableHandle) -> Self {
-        Self {
-            thumb_state: Default::default(),
-            parent_id: None,
-            scroll_handle: Arc::new(scroll),
-            auto_hide: Rc::new(RefCell::new(AutoHide::Disabled)),
-        }
-    }
-
-    /// Set a parent model which should be notified whenever this Scrollbar gets a scroll event.
-    pub fn parent_entity<V: 'static>(mut self, v: &Entity<V>) -> Self {
-        self.parent_id = Some(v.entity_id());
-        self
-    }
-
-    pub fn scroll_handle(&self) -> &Arc<dyn ScrollableHandle> {
-        &self.scroll_handle
-    }
-
-    pub fn is_dragging(&self) -> bool {
-        matches!(self.thumb_state.get(), ThumbState::Dragging(_))
-    }
+impl ScrollbarLayout {
+    fn compute_click_offset(
+        &self,
+        event_position: Point<Pixels>,
+        max_offset: Size<Pixels>,
+        event_type: ScrollbarMouseEvent,
+    ) -> Pixels {
+        let Self {
+            track_bounds,
+            thumb_bounds,
+            axis,
+            ..
+        } = self;
+        let axis = *axis;
+
+        let viewport_size = track_bounds.size.along(axis);
+        let thumb_size = thumb_bounds.size.along(axis);
+        let thumb_offset = match event_type {
+            ScrollbarMouseEvent::TrackClick => thumb_size / 2.,
+            ScrollbarMouseEvent::ThumbDrag(thumb_offset) => thumb_offset,
+        };
 
-    fn set_dragging(&self, drag_offset: Pixels) {
-        self.set_thumb_state(ThumbState::Dragging(drag_offset));
-        self.scroll_handle.drag_started();
-    }
+        let thumb_start =
+            (event_position.along(axis) - track_bounds.origin.along(axis) - thumb_offset)
+                .clamp(px(0.), viewport_size - thumb_size);
 
-    fn set_thumb_hovered(&self, hovered: bool) {
-        self.set_thumb_state(if hovered {
-            ThumbState::Hover
+        let max_offset = max_offset.along(axis);
+        let percentage = if viewport_size > thumb_size {
+            thumb_start / (viewport_size - thumb_size)
         } else {
-            ThumbState::Inactive
-        });
-    }
-
-    fn set_thumb_state(&self, state: ThumbState) {
-        self.thumb_state.set(state);
-    }
-
-    fn thumb_range(&self, axis: ScrollbarAxis) -> Option<Range<f32>> {
-        const MINIMUM_THUMB_SIZE: Pixels = px(25.);
-        let max_offset = self.scroll_handle.max_offset().along(axis);
-        let viewport_size = self.scroll_handle.viewport().size.along(axis);
-        if max_offset.is_zero() || viewport_size.is_zero() {
-            return None;
-        }
-        let content_size = viewport_size + max_offset;
-        let visible_percentage = viewport_size / content_size;
-        let thumb_size = MINIMUM_THUMB_SIZE.max(viewport_size * visible_percentage);
-        if thumb_size > viewport_size {
-            return None;
-        }
-        let current_offset = self
-            .scroll_handle
-            .offset()
-            .along(axis)
-            .clamp(-max_offset, Pixels::ZERO)
-            .abs();
-        let start_offset = (current_offset / max_offset) * (viewport_size - thumb_size);
-        let thumb_percentage_start = start_offset / viewport_size;
-        let thumb_percentage_end = (start_offset + thumb_size) / viewport_size;
-        Some(thumb_percentage_start..thumb_percentage_end)
-    }
-
-    fn show_temporarily(&self, parent_id: EntityId, cx: &mut App) {
-        const SHOW_INTERVAL: Duration = Duration::from_secs(1);
-
-        let auto_hide = self.auto_hide.clone();
-        auto_hide.replace(AutoHide::Visible {
-            parent_id,
-            _task: cx.spawn({
-                let this = auto_hide.clone();
-                async move |cx| {
-                    cx.background_executor().timer(SHOW_INTERVAL).await;
-                    this.replace(AutoHide::Hidden { parent_id });
-                    cx.update(|cx| {
-                        cx.notify(parent_id);
-                    })
-                    .ok();
-                }
-            }),
-        });
-    }
-
-    fn unhide(&self, position: &Point<Pixels>, cx: &mut App) {
-        let parent_id = match &*self.auto_hide.borrow() {
-            AutoHide::Disabled => return,
-            AutoHide::Hidden { parent_id } => *parent_id,
-            AutoHide::Visible { parent_id, _task } => *parent_id,
+            0.
         };
 
-        if self.scroll_handle().viewport().contains(position) {
-            self.show_temporarily(parent_id, cx);
-        }
+        -max_offset * percentage
     }
 }
 
-impl Scrollbar {
-    pub fn vertical(state: ScrollbarState) -> Option<Self> {
-        Self::new(state, ScrollbarAxis::Vertical)
+impl PartialEq for ScrollbarLayout {
+    fn eq(&self, other: &Self) -> bool {
+        self.axis == other.axis && self.thumb_bounds == other.thumb_bounds
     }
+}
 
-    pub fn horizontal(state: ScrollbarState) -> Option<Self> {
-        Self::new(state, ScrollbarAxis::Horizontal)
+pub struct ScrollbarPrepaintState {
+    parent_bounds: Bounds<Pixels>,
+    thumbs: SmallVec<[ScrollbarLayout; 2]>,
+}
+
+impl ScrollbarPrepaintState {
+    fn thumb_for_position(&self, position: &Point<Pixels>) -> Option<&ScrollbarLayout> {
+        self.thumbs
+            .iter()
+            .find(|info| info.thumb_bounds.contains(position))
     }
 
-    fn new(state: ScrollbarState, kind: ScrollbarAxis) -> Option<Self> {
-        let thumb = state.thumb_range(kind)?;
-        Some(Self { thumb, state, kind })
+    fn hit_for_position(&self, position: &Point<Pixels>) -> Option<&ScrollbarLayout> {
+        self.thumbs.iter().find(|info| {
+            if info.reserved_space.needs_scroll_track() {
+                info.track_bounds.contains(position)
+            } else {
+                info.thumb_bounds.contains(position)
+            }
+        })
     }
+}
 
-    /// Automatically hide the scrollbar when idle
-    pub fn auto_hide<V: 'static>(self, cx: &mut Context<V>) -> Self {
-        if matches!(*self.state.auto_hide.borrow(), AutoHide::Disabled) {
-            self.state.show_temporarily(cx.entity_id(), cx);
-        }
-        self
+impl PartialEq for ScrollbarPrepaintState {
+    fn eq(&self, other: &Self) -> bool {
+        self.thumbs == other.thumbs
     }
 }
 
-impl Element for Scrollbar {
+impl<S: ScrollbarVisibility, T: ScrollableHandle> Element for ScrollbarElement<S, T> {
     type RequestLayoutState = ();
-    type PrepaintState = Hitbox;
+    type PrepaintState = Option<ScrollbarPrepaintState>;
 
     fn id(&self) -> Option<ElementId> {
         None