1use std::{
2 any::Any,
3 fmt::Debug,
4 ops::Not,
5 time::{Duration, Instant},
6};
7
8use gpui::{
9 Along, Anchor, App, AppContext as _, Axis as ScrollbarAxis, BorderStyle, Bounds, ContentMask,
10 Context, Corners, CursorStyle, DispatchPhase, Div, Edges, Element, ElementId, Entity, EntityId,
11 GlobalElementId, Hitbox, HitboxBehavior, Hsla, InteractiveElement, IntoElement, IsZero,
12 LayoutId, ListState, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement,
13 Pixels, Point, Position, Render, ScrollHandle, ScrollWheelEvent, Size, Stateful,
14 StatefulInteractiveElement, Style, Styled, Task, UniformListDecoration,
15 UniformListScrollHandle, Window, ease_in_out, prelude::FluentBuilder as _, px, quad, relative,
16 size,
17};
18use gpui_util::ResultExt;
19use smallvec::SmallVec;
20use theme::ActiveTheme as _;
21
22use std::ops::Range;
23
24use crate::scrollbars::{ScrollbarAutoHide, ScrollbarVisibility, ShowScrollbar};
25
26const SCROLLBAR_HIDE_DELAY_INTERVAL: Duration = Duration::from_secs(1);
27const SCROLLBAR_HIDE_DURATION: Duration = Duration::from_millis(400);
28const SCROLLBAR_SHOW_DURATION: Duration = Duration::from_millis(50);
29
30pub const EDITOR_SCROLLBAR_WIDTH: Pixels = ScrollbarStyle::Editor.to_pixels();
31const SCROLLBAR_PADDING: Pixels = px(4.);
32const BORDER_WIDTH: Pixels = px(1.);
33
34pub mod scrollbars {
35 use gpui::{App, Global};
36 use schemars::JsonSchema;
37 use serde::{Deserialize, Serialize};
38
39 #[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
43 #[serde(rename_all = "snake_case")]
44 pub enum ShowScrollbar {
45 #[default]
48 Auto,
49 System,
51 Always,
53 Never,
55 }
56
57 pub trait ScrollbarVisibility: 'static {
58 fn visibility(&self, cx: &App) -> ShowScrollbar;
59 }
60
61 #[derive(Default)]
62 pub struct ScrollbarAutoHide(pub bool);
63
64 impl ScrollbarAutoHide {
65 pub fn should_hide(&self) -> bool {
66 self.0
67 }
68 }
69
70 impl Global for ScrollbarAutoHide {}
71}
72
73fn get_scrollbar_state<T>(
74 mut config: Scrollbars<T>,
75 caller_location: &'static std::panic::Location,
76 window: &mut Window,
77 cx: &mut App,
78) -> Entity<ScrollbarStateWrapper<T>>
79where
80 T: ScrollableHandle,
81{
82 let element_id = config.id.take().unwrap_or_else(|| caller_location.into());
83 let track_color = config.track_color;
84 let has_border = config.border;
85
86 let state = window.use_keyed_state(element_id, cx, |_, cx| {
87 let parent_id = cx.entity_id();
88 ScrollbarStateWrapper(cx.new(|cx| ScrollbarState::new_from_config(config, parent_id, cx)))
89 });
90
91 state.update(cx, |state, cx| {
92 state.0.update(cx, |state, _cx| {
93 state.update_colors(track_color, has_border)
94 })
95 });
96 state
97}
98
99pub trait WithScrollbar: Sized {
100 type Output;
101
102 fn custom_scrollbars<T>(
103 self,
104 config: Scrollbars<T>,
105 window: &mut Window,
106 cx: &mut App,
107 ) -> Self::Output
108 where
109 T: ScrollableHandle;
110
111 #[track_caller]
131 fn vertical_scrollbar_for<ScrollHandle: ScrollableHandle + Clone>(
132 self,
133 scroll_handle: &ScrollHandle,
134 window: &mut Window,
135 cx: &mut App,
136 ) -> Self::Output {
137 self.custom_scrollbars(
138 Scrollbars::new(ScrollAxes::Vertical)
139 .tracked_scroll_handle(scroll_handle)
140 .ensure_id(core::panic::Location::caller()),
141 window,
142 cx,
143 )
144 }
145}
146
147impl WithScrollbar for Stateful<Div> {
148 type Output = Self;
149
150 #[track_caller]
151 fn custom_scrollbars<T>(
152 self,
153 config: Scrollbars<T>,
154 window: &mut Window,
155 cx: &mut App,
156 ) -> Self::Output
157 where
158 T: ScrollableHandle,
159 {
160 render_scrollbar(
161 get_scrollbar_state(config, std::panic::Location::caller(), window, cx),
162 self,
163 cx,
164 )
165 }
166}
167
168impl WithScrollbar for Div {
169 type Output = Stateful<Div>;
170
171 #[track_caller]
172 fn custom_scrollbars<T>(
173 self,
174 config: Scrollbars<T>,
175 window: &mut Window,
176 cx: &mut App,
177 ) -> Self::Output
178 where
179 T: ScrollableHandle,
180 {
181 let scrollbar = get_scrollbar_state(config, std::panic::Location::caller(), window, cx);
182 let scrollbar_entity_id = scrollbar.entity_id();
185
186 render_scrollbar(
187 scrollbar,
188 self.id(("track-scroll", scrollbar_entity_id)),
189 cx,
190 )
191 }
192}
193
194fn render_scrollbar<T>(
195 scrollbar: Entity<ScrollbarStateWrapper<T>>,
196 div: Stateful<Div>,
197 cx: &App,
198) -> Stateful<Div>
199where
200 T: ScrollableHandle,
201{
202 let state = &scrollbar.read(cx).0;
203
204 div.when_some(state.read(cx).handle_to_track(), |this, handle| {
205 this.track_scroll(handle).when_some(
206 state.read(cx).visible_axes(),
207 |this, axes| match axes {
208 ScrollAxes::Horizontal => this.overflow_x_scroll(),
209 ScrollAxes::Vertical => this.overflow_y_scroll(),
210 ScrollAxes::Both => this.overflow_scroll(),
211 },
212 )
213 })
214 .when_some(
215 state
216 .read(cx)
217 .space_to_reserve_for(ScrollbarAxis::Horizontal),
218 |this, space| this.pb(space),
219 )
220 .when_some(
221 state.read(cx).space_to_reserve_for(ScrollbarAxis::Vertical),
222 |this, space| this.pr(space),
223 )
224 .child(state.clone())
225}
226
227impl<T: ScrollableHandle> UniformListDecoration for ScrollbarStateWrapper<T> {
228 fn compute(
229 &self,
230 _visible_range: Range<usize>,
231 _bounds: Bounds<Pixels>,
232 scroll_offset: Point<Pixels>,
233 _item_height: Pixels,
234 _item_count: usize,
235 _window: &mut Window,
236 _cx: &mut App,
237 ) -> gpui::AnyElement {
238 ScrollbarElement {
239 origin: -scroll_offset,
240 state: self.0.clone(),
241 }
242 .into_any()
243 }
244}
245
246#[derive(Copy, Clone, PartialEq, Eq)]
276enum ShowBehavior {
277 Always,
278 Autohide,
279 Never,
280}
281
282impl ShowBehavior {
283 fn from_setting(setting: ShowScrollbar, cx: &mut App) -> Self {
284 match setting {
285 ShowScrollbar::Never => Self::Never,
286 ShowScrollbar::Auto => Self::Autohide,
287 ShowScrollbar::System => {
288 if cx.default_global::<ScrollbarAutoHide>().should_hide() {
289 Self::Autohide
290 } else {
291 Self::Always
292 }
293 }
294 ShowScrollbar::Always => Self::Always,
295 }
296 }
297}
298
299pub enum ScrollAxes {
300 Horizontal,
301 Vertical,
302 Both,
303}
304
305impl ScrollAxes {
306 fn apply_to<T>(self, point: Point<T>, value: T) -> Point<T>
307 where
308 T: Debug + Default + PartialEq + Clone,
309 {
310 match self {
311 Self::Horizontal => point.apply_along(ScrollbarAxis::Horizontal, |_| value),
312 Self::Vertical => point.apply_along(ScrollbarAxis::Vertical, |_| value),
313 Self::Both => Point::new(value.clone(), value),
314 }
315 }
316}
317
318#[derive(Clone, Debug, Default, PartialEq)]
319enum ReservedSpace {
320 #[default]
321 None,
322 Thumb,
323 Track,
324 StableTrack,
325}
326
327impl ReservedSpace {
328 fn is_visible(&self) -> bool {
329 *self != ReservedSpace::None
330 }
331
332 fn needs_scroll_track(&self) -> bool {
333 matches!(self, Self::Track | Self::StableTrack)
334 }
335
336 fn needs_space_reserved(&self, max_offset: Pixels) -> bool {
337 match self {
338 Self::StableTrack => true,
339 Self::Track => !max_offset.is_zero(),
340 _ => false,
341 }
342 }
343}
344
345#[derive(Clone)]
346enum Handle<T: ScrollableHandle> {
347 Tracked(T),
348 Untracked(fn() -> T),
349}
350
351#[derive(Clone, Copy, Default, PartialEq)]
352pub enum ScrollbarStyle {
353 #[default]
354 Regular,
355 Editor,
356}
357
358impl ScrollbarStyle {
359 pub const fn to_pixels(&self) -> Pixels {
360 match self {
361 ScrollbarStyle::Regular => px(6.),
362 ScrollbarStyle::Editor => px(15.),
363 }
364 }
365}
366
367#[derive(Clone)]
368pub struct Scrollbars<T: ScrollableHandle = ScrollHandle> {
369 id: Option<ElementId>,
370 get_visibility: fn(&App) -> ShowScrollbar,
371 tracked_entity: Option<Option<EntityId>>,
372 scrollable_handle: Handle<T>,
373 visibility: Point<ReservedSpace>,
374 style: Option<ScrollbarStyle>,
375 track_color: Option<Hsla>,
376 border: bool,
377}
378
379impl Scrollbars {
380 pub fn new(show_along: ScrollAxes) -> Self {
381 Self::new_with_setting(show_along, |_| ShowScrollbar::default())
382 }
383
384 pub fn always_visible(show_along: ScrollAxes) -> Self {
385 Self::new_with_setting(show_along, |_| ShowScrollbar::Always)
386 }
387
388 pub fn for_settings<S: ScrollbarVisibility + Default>() -> Scrollbars {
389 Scrollbars::new_with_setting(ScrollAxes::Both, |cx| S::default().visibility(cx))
390 }
391}
392
393impl Scrollbars {
394 fn new_with_setting(show_along: ScrollAxes, get_visibility: fn(&App) -> ShowScrollbar) -> Self {
395 Self {
396 id: None,
397 get_visibility,
398 scrollable_handle: Handle::Untracked(ScrollHandle::new),
399 tracked_entity: None,
400 visibility: show_along.apply_to(Default::default(), ReservedSpace::Thumb),
401 style: None,
402 track_color: None,
403 border: false,
404 }
405 }
406}
407
408impl<ScrollHandle: ScrollableHandle> Scrollbars<ScrollHandle> {
409 pub fn id(mut self, id: impl Into<ElementId>) -> Self {
410 self.id = Some(id.into());
411 self
412 }
413
414 fn ensure_id(mut self, id: impl Into<ElementId>) -> Self {
415 if self.id.is_none() {
416 self.id = Some(id.into());
417 }
418 self
419 }
420
421 pub fn notify_content(mut self) -> Self {
423 self.tracked_entity = Some(None);
424 self
425 }
426
427 pub fn tracked_entity(mut self, entity_id: EntityId) -> Self {
429 self.tracked_entity = Some(Some(entity_id));
430 self
431 }
432
433 pub fn tracked_scroll_handle<TrackedHandle: ScrollableHandle>(
434 self,
435 tracked_scroll_handle: &TrackedHandle,
436 ) -> Scrollbars<TrackedHandle> {
437 let Self {
438 id,
439 tracked_entity: tracked_entity_id,
440 visibility,
441 get_visibility,
442 track_color,
443 border,
444 style,
445 ..
446 } = self;
447
448 Scrollbars {
449 scrollable_handle: Handle::Tracked(tracked_scroll_handle.clone()),
450 id,
451 tracked_entity: tracked_entity_id,
452 visibility,
453 track_color,
454 border,
455 get_visibility,
456 style,
457 }
458 }
459
460 pub fn show_along(mut self, along: ScrollAxes) -> Self {
461 self.visibility = along.apply_to(self.visibility, ReservedSpace::Thumb);
462 self
463 }
464
465 pub fn style(mut self, style: ScrollbarStyle) -> Self {
466 self.style = Some(style);
467 self
468 }
469
470 pub fn with_track_along(mut self, along: ScrollAxes, background_color: Hsla) -> Self {
471 self.visibility = along.apply_to(self.visibility, ReservedSpace::Track);
472 self.track_color = Some(background_color);
473 self
474 }
475
476 pub fn with_stable_track_along(mut self, along: ScrollAxes, background_color: Hsla) -> Self {
477 self.visibility = along.apply_to(self.visibility, ReservedSpace::StableTrack);
478 self.track_color = Some(background_color);
479 self.border = true;
480 self
481 }
482}
483
484#[derive(PartialEq, Clone, Debug)]
485enum VisibilityState {
486 Visible,
487 Animating { showing: bool, delta: f32 },
488 ThumbHidden,
489 Hidden,
490 Disabled,
491}
492
493enum AnimationState {
494 InProgress {
495 current_delta: f32,
496 animation_duration: Duration,
497 showing: bool,
498 },
499 Stale,
500}
501
502const DELTA_MAX: f32 = 1.0;
503
504impl VisibilityState {
505 fn from_behavior(behavior: ShowBehavior) -> Self {
506 match behavior {
507 ShowBehavior::Always => Self::Visible,
508 ShowBehavior::Never => Self::Disabled,
509 ShowBehavior::Autohide => Self::for_show(),
510 }
511 }
512
513 fn for_show() -> Self {
514 Self::Animating {
515 showing: true,
516 delta: Default::default(),
517 }
518 }
519
520 fn for_autohide() -> Self {
521 Self::Animating {
522 showing: Default::default(),
523 delta: Default::default(),
524 }
525 }
526
527 fn is_visible(&self) -> bool {
528 matches!(
529 self,
530 Self::Visible | Self::Animating { .. } | Self::ThumbHidden
531 )
532 }
533
534 #[inline]
535 fn is_disabled(&self) -> bool {
536 *self == VisibilityState::Disabled
537 }
538
539 fn animation_state(&self) -> Option<AnimationState> {
540 match self {
541 Self::ThumbHidden => Some(AnimationState::Stale),
542 Self::Animating { showing, delta } => Some(AnimationState::InProgress {
543 current_delta: *delta,
544 animation_duration: if *showing {
545 SCROLLBAR_SHOW_DURATION
546 } else {
547 SCROLLBAR_HIDE_DURATION
548 },
549 showing: *showing,
550 }),
551 _ => None,
552 }
553 }
554
555 fn set_delta(&mut self, new_delta: f32, keep_track_visible: bool) {
556 match self {
557 Self::Animating { showing, delta } if new_delta >= DELTA_MAX => {
558 if *showing {
559 *self = Self::Visible;
560 } else if keep_track_visible {
561 *self = Self::ThumbHidden;
562 } else {
563 *self = Self::Hidden;
564 }
565 }
566 Self::Animating { delta, .. } => *delta = new_delta,
567 _ => {}
568 }
569 }
570
571 fn toggle_visible(&self, show_behavior: ShowBehavior) -> Self {
572 match self {
573 Self::Hidden | Self::ThumbHidden => {
574 if show_behavior == ShowBehavior::Autohide {
575 Self::for_show()
576 } else {
577 Self::Visible
578 }
579 }
580 Self::Animating {
581 showing: false,
582 delta: progress,
583 } => Self::Animating {
584 showing: true,
585 delta: DELTA_MAX - progress,
586 },
587 _ => self.clone(),
588 }
589 }
590}
591
592enum ParentHoverEvent {
593 Within,
594 Entered,
595 Exited,
596 Outside,
597}
598
599#[derive(Clone)]
600struct TrackColors {
601 background: Hsla,
602 has_border: bool,
603}
604
605pub fn on_new_scrollbars<T: gpui::Global>(cx: &mut App) {
606 cx.observe_new::<ScrollbarState>(|_, window, cx| {
607 if let Some(window) = window {
608 cx.observe_global_in::<T>(window, ScrollbarState::settings_changed)
609 .detach();
610 }
611 })
612 .detach();
613}
614
615struct ScrollbarStateWrapper<T: ScrollableHandle>(Entity<ScrollbarState<T>>);
618
619struct ScrollbarState<T: ScrollableHandle = ScrollHandle> {
621 thumb_state: ThumbState,
622 notify_id: Option<EntityId>,
623 manually_added: bool,
624 scroll_handle: T,
625 show_behavior: ShowBehavior,
626 get_visibility: fn(&App) -> ShowScrollbar,
627 visibility: Point<ReservedSpace>,
628 track_color: Option<TrackColors>,
629 show_state: VisibilityState,
630 style: ScrollbarStyle,
631 mouse_in_parent: bool,
632 last_prepaint_state: Option<ScrollbarPrepaintState>,
633 _auto_hide_task: Option<Task<()>>,
634}
635
636impl<T: ScrollableHandle> ScrollbarState<T> {
637 fn new_from_config(config: Scrollbars<T>, parent_id: EntityId, cx: &mut Context<Self>) -> Self {
638 let (manually_added, scroll_handle) = match config.scrollable_handle {
639 Handle::Tracked(handle) => (true, handle),
640 Handle::Untracked(func) => (false, func()),
641 };
642
643 let show_behavior = ShowBehavior::from_setting((config.get_visibility)(cx), cx);
644 ScrollbarState {
645 thumb_state: Default::default(),
646 notify_id: config.tracked_entity.map(|id| id.unwrap_or(parent_id)),
647 manually_added,
648 scroll_handle,
649 visibility: config.visibility,
650 track_color: config.track_color.map(|color| TrackColors {
651 background: color,
652 has_border: config.border,
653 }),
654 show_behavior,
655 get_visibility: config.get_visibility,
656 style: config.style.unwrap_or_default(),
657 show_state: VisibilityState::from_behavior(show_behavior),
658 mouse_in_parent: true,
659 last_prepaint_state: None,
660 _auto_hide_task: None,
661 }
662 }
663
664 fn settings_changed(&mut self, window: &mut Window, cx: &mut Context<Self>) {
665 self.set_show_behavior(
666 ShowBehavior::from_setting((self.get_visibility)(cx), cx),
667 window,
668 cx,
669 );
670 }
671
672 fn schedule_auto_hide(&mut self, window: &mut Window, cx: &mut Context<Self>) {
674 if self._auto_hide_task.is_none() {
675 self._auto_hide_task = (self.visible() && self.show_behavior == ShowBehavior::Autohide)
676 .then(|| {
677 cx.spawn_in(window, async move |scrollbar_state, cx| {
678 cx.background_executor()
679 .timer(SCROLLBAR_HIDE_DELAY_INTERVAL)
680 .await;
681 scrollbar_state
682 .update(cx, |state, cx| {
683 if state.thumb_state == ThumbState::Inactive {
684 state.set_visibility(VisibilityState::for_autohide(), cx);
685 }
686 state._auto_hide_task.take();
687 })
688 .log_err();
689 })
690 });
691 }
692 }
693
694 fn show_scrollbars(&mut self, window: &mut Window, cx: &mut Context<Self>) {
695 let visibility = self.show_state.toggle_visible(self.show_behavior);
696 self.set_visibility(visibility, cx);
697 self._auto_hide_task.take();
698 self.schedule_auto_hide(window, cx);
699 }
700
701 fn set_show_behavior(
702 &mut self,
703 behavior: ShowBehavior,
704 window: &mut Window,
705 cx: &mut Context<Self>,
706 ) {
707 if self.show_behavior != behavior {
708 self.show_behavior = behavior;
709 self.set_visibility(VisibilityState::from_behavior(behavior), cx);
710 self.schedule_auto_hide(window, cx);
711 cx.notify();
712 }
713 }
714
715 fn set_visibility(&mut self, visibility: VisibilityState, cx: &mut Context<Self>) {
716 if self.show_state != visibility {
717 self.show_state = visibility;
718 cx.notify();
719 }
720 }
721
722 #[inline]
723 fn visible_axes(&self) -> Option<ScrollAxes> {
724 match (&self.visibility.x, &self.visibility.y) {
725 (ReservedSpace::None, ReservedSpace::None) => None,
726 (ReservedSpace::None, _) => Some(ScrollAxes::Vertical),
727 (_, ReservedSpace::None) => Some(ScrollAxes::Horizontal),
728 _ => Some(ScrollAxes::Both),
729 }
730 }
731
732 fn space_to_reserve_for(&self, axis: ScrollbarAxis) -> Option<Pixels> {
733 (self.show_state.is_disabled().not()
734 && self
735 .visibility
736 .along(axis)
737 .needs_space_reserved(self.scroll_handle().max_offset().along(axis)))
738 .then(|| self.space_to_reserve())
739 }
740
741 fn space_to_reserve(&self) -> Pixels {
742 self.style.to_pixels() + 2 * SCROLLBAR_PADDING
743 }
744
745 fn handle_to_track<Handle: ScrollableHandle>(&self) -> Option<&Handle> {
746 (!self.manually_added)
747 .then(|| (self.scroll_handle() as &dyn Any).downcast_ref::<Handle>())
748 .flatten()
749 }
750
751 fn scroll_handle(&self) -> &T {
752 &self.scroll_handle
753 }
754
755 fn set_offset(&mut self, offset: Point<Pixels>, cx: &mut Context<Self>) {
756 self.scroll_handle.set_offset(offset);
757 self.notify_parent(cx);
758 cx.notify();
759 }
760
761 fn is_dragging(&self) -> bool {
762 self.thumb_state.is_dragging()
763 }
764
765 fn set_dragging(
766 &mut self,
767 axis: ScrollbarAxis,
768 drag_offset: Pixels,
769 window: &mut Window,
770 cx: &mut Context<Self>,
771 ) {
772 self.set_thumb_state(ThumbState::Dragging(axis, drag_offset), window, cx);
773 self.scroll_handle().drag_started();
774 }
775
776 fn update_hovered_thumb(
777 &mut self,
778 position: &Point<Pixels>,
779 window: &mut Window,
780 cx: &mut Context<Self>,
781 ) {
782 self.set_thumb_state(
783 if let Some(&ScrollbarLayout { axis, .. }) =
784 self.last_prepaint_state.as_ref().and_then(|state| {
785 state
786 .thumb_for_position(position)
787 .filter(|thumb| thumb.cursor_hitbox.is_hovered(window))
788 })
789 {
790 ThumbState::Hover(axis)
791 } else {
792 ThumbState::Inactive
793 },
794 window,
795 cx,
796 );
797 }
798
799 fn set_thumb_state(&mut self, state: ThumbState, window: &mut Window, cx: &mut Context<Self>) {
800 if self.thumb_state != state {
801 if state == ThumbState::Inactive {
802 self.schedule_auto_hide(window, cx);
803 } else {
804 self.set_visibility(self.show_state.toggle_visible(self.show_behavior), cx);
805 self._auto_hide_task.take();
806 }
807 self.thumb_state = state;
808 cx.notify();
809 }
810 }
811
812 fn update_parent_hovered(&mut self, window: &Window) -> ParentHoverEvent {
813 let last_parent_hovered = self.mouse_in_parent;
814 self.mouse_in_parent = self.parent_hovered(window);
815 let state_changed = self.mouse_in_parent != last_parent_hovered;
816 match (self.mouse_in_parent, state_changed) {
817 (true, true) => ParentHoverEvent::Entered,
818 (true, false) => ParentHoverEvent::Within,
819 (false, true) => ParentHoverEvent::Exited,
820 (false, false) => ParentHoverEvent::Outside,
821 }
822 }
823
824 fn update_colors(&mut self, track_color: Option<Hsla>, has_border: bool) {
825 self.track_color = track_color.map(|color| TrackColors {
826 background: color,
827 has_border,
828 });
829 }
830
831 fn parent_hovered(&self, window: &Window) -> bool {
832 self.last_prepaint_state
833 .as_ref()
834 .is_some_and(|state| state.parent_bounds_hitbox.is_hovered(window))
835 }
836
837 fn hit_for_position(&self, position: &Point<Pixels>) -> Option<&ScrollbarLayout> {
838 self.last_prepaint_state
839 .as_ref()
840 .and_then(|state| state.hit_for_position(position))
841 }
842
843 fn thumb_for_axis(&self, axis: ScrollbarAxis) -> Option<&ScrollbarLayout> {
844 self.last_prepaint_state
845 .as_ref()
846 .and_then(|state| state.thumbs.iter().find(|thumb| thumb.axis == axis))
847 }
848
849 fn thumb_ranges(
850 &self,
851 ) -> impl Iterator<Item = (ScrollbarAxis, Range<f32>, ReservedSpace)> + '_ {
852 const MINIMUM_THUMB_SIZE: Pixels = px(25.);
853 let max_offset = self.scroll_handle().max_offset();
854 let viewport_size = self.scroll_handle().viewport().size;
855 let current_offset = self.scroll_handle().offset();
856
857 [ScrollbarAxis::Horizontal, ScrollbarAxis::Vertical]
858 .into_iter()
859 .filter(|&axis| self.visibility.along(axis).is_visible())
860 .flat_map(move |axis| {
861 let max_offset = max_offset.along(axis);
862 let viewport_size = viewport_size.along(axis);
863 if max_offset.is_zero() || viewport_size.is_zero() {
864 return None;
865 }
866 let content_size = viewport_size + max_offset;
867 let visible_percentage = viewport_size / content_size;
868 let thumb_size = MINIMUM_THUMB_SIZE.max(viewport_size * visible_percentage);
869 if thumb_size > viewport_size {
870 return None;
871 }
872 let current_offset = current_offset
873 .along(axis)
874 .clamp(-max_offset, Pixels::ZERO)
875 .abs();
876 let start_offset = (current_offset / max_offset) * (viewport_size - thumb_size);
877 let thumb_percentage_start = start_offset / viewport_size;
878 let thumb_percentage_end = (start_offset + thumb_size) / viewport_size;
879 Some((
880 axis,
881 thumb_percentage_start..thumb_percentage_end,
882 self.visibility.along(axis),
883 ))
884 })
885 }
886
887 fn visible(&self) -> bool {
888 self.show_state.is_visible()
889 }
890
891 #[inline]
892 fn disabled(&self) -> bool {
893 self.show_state.is_disabled()
894 }
895
896 fn notify_parent(&self, cx: &mut App) {
897 if let Some(entity_id) = self.notify_id {
898 cx.notify(entity_id);
899 }
900 }
901}
902
903impl<T: ScrollableHandle> Render for ScrollbarState<T> {
904 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
905 ScrollbarElement {
906 state: cx.entity(),
907 origin: Default::default(),
908 }
909 }
910}
911
912struct ScrollbarElement<T: ScrollableHandle> {
913 origin: Point<Pixels>,
914 state: Entity<ScrollbarState<T>>,
915}
916
917#[derive(Default, Debug, PartialEq, Eq)]
918enum ThumbState {
919 #[default]
920 Inactive,
921 Hover(ScrollbarAxis),
922 Dragging(ScrollbarAxis, Pixels),
923}
924
925impl ThumbState {
926 fn is_dragging(&self) -> bool {
927 matches!(*self, ThumbState::Dragging(..))
928 }
929}
930
931impl ScrollableHandle for UniformListScrollHandle {
932 fn max_offset(&self) -> Point<Pixels> {
933 self.0.borrow().base_handle.max_offset()
934 }
935
936 fn set_offset(&self, point: Point<Pixels>) {
937 self.0.borrow().base_handle.set_offset(point);
938 }
939
940 fn offset(&self) -> Point<Pixels> {
941 self.0.borrow().base_handle.offset()
942 }
943
944 fn viewport(&self) -> Bounds<Pixels> {
945 self.0.borrow().base_handle.bounds()
946 }
947}
948
949impl ScrollableHandle for ListState {
950 fn max_offset(&self) -> Point<Pixels> {
951 self.max_offset_for_scrollbar()
952 }
953
954 fn set_offset(&self, point: Point<Pixels>) {
955 self.set_offset_from_scrollbar(point);
956 }
957
958 fn offset(&self) -> Point<Pixels> {
959 self.scroll_px_offset_for_scrollbar()
960 }
961
962 fn drag_started(&self) {
963 self.scrollbar_drag_started();
964 }
965
966 fn drag_ended(&self) {
967 self.scrollbar_drag_ended();
968 }
969
970 fn viewport(&self) -> Bounds<Pixels> {
971 self.viewport_bounds()
972 }
973}
974
975impl ScrollableHandle for ScrollHandle {
976 fn max_offset(&self) -> Point<Pixels> {
977 self.max_offset()
978 }
979
980 fn set_offset(&self, point: Point<Pixels>) {
981 self.set_offset(point);
982 }
983
984 fn offset(&self) -> Point<Pixels> {
985 self.offset()
986 }
987
988 fn viewport(&self) -> Bounds<Pixels> {
989 self.bounds()
990 }
991}
992
993pub trait ScrollableHandle: 'static + Any + Sized + Clone {
994 fn max_offset(&self) -> Point<Pixels>;
995 fn set_offset(&self, point: Point<Pixels>);
996 fn offset(&self) -> Point<Pixels>;
997 fn viewport(&self) -> Bounds<Pixels>;
998 fn drag_started(&self) {}
999 fn drag_ended(&self) {}
1000
1001 fn scrollable_along(&self, axis: ScrollbarAxis) -> bool {
1002 self.max_offset().along(axis) > Pixels::ZERO
1003 }
1004 fn content_size(&self) -> Size<Pixels> {
1005 self.viewport().size + self.max_offset().into()
1006 }
1007}
1008
1009enum ScrollbarMouseEvent {
1010 TrackClick,
1011 ThumbDrag(Pixels),
1012}
1013
1014struct ScrollbarLayout {
1015 thumb_bounds: Bounds<Pixels>,
1016 track_bounds: Bounds<Pixels>,
1017 cursor_hitbox: Hitbox,
1018 reserved_space: ReservedSpace,
1019 track_config: Option<(Bounds<Pixels>, TrackColors)>,
1020 axis: ScrollbarAxis,
1021}
1022
1023impl ScrollbarLayout {
1024 fn compute_click_offset(
1025 &self,
1026 event_position: Point<Pixels>,
1027 max_offset: Point<Pixels>,
1028 event_type: ScrollbarMouseEvent,
1029 ) -> Pixels {
1030 let Self {
1031 track_bounds,
1032 thumb_bounds,
1033 axis,
1034 ..
1035 } = self;
1036 let axis = *axis;
1037
1038 let viewport_size = track_bounds.size.along(axis);
1039 let thumb_size = thumb_bounds.size.along(axis);
1040 let thumb_offset = match event_type {
1041 ScrollbarMouseEvent::TrackClick => thumb_size / 2.,
1042 ScrollbarMouseEvent::ThumbDrag(thumb_offset) => thumb_offset,
1043 };
1044
1045 let thumb_start =
1046 (event_position.along(axis) - track_bounds.origin.along(axis) - thumb_offset)
1047 .clamp(px(0.), viewport_size - thumb_size);
1048
1049 let max_offset = max_offset.along(axis);
1050 let percentage = if viewport_size > thumb_size {
1051 thumb_start / (viewport_size - thumb_size)
1052 } else {
1053 0.
1054 };
1055
1056 -max_offset * percentage
1057 }
1058}
1059
1060impl PartialEq for ScrollbarLayout {
1061 fn eq(&self, other: &Self) -> bool {
1062 if self.axis != other.axis {
1063 return false;
1064 }
1065
1066 let axis = self.axis;
1067 let thumb_offset =
1068 self.thumb_bounds.origin.along(axis) - self.track_bounds.origin.along(axis);
1069 let other_thumb_offset =
1070 other.thumb_bounds.origin.along(axis) - other.track_bounds.origin.along(axis);
1071
1072 thumb_offset == other_thumb_offset
1073 && self.thumb_bounds.size.along(axis) == other.thumb_bounds.size.along(axis)
1074 }
1075}
1076
1077pub struct ScrollbarPrepaintState {
1078 parent_bounds_hitbox: Hitbox,
1079 thumbs: SmallVec<[ScrollbarLayout; 2]>,
1080}
1081
1082impl ScrollbarPrepaintState {
1083 fn thumb_for_position(&self, position: &Point<Pixels>) -> Option<&ScrollbarLayout> {
1084 self.thumbs
1085 .iter()
1086 .find(|info| info.thumb_bounds.contains(position))
1087 }
1088
1089 fn hit_for_position(&self, position: &Point<Pixels>) -> Option<&ScrollbarLayout> {
1090 self.thumbs.iter().find(|info| {
1091 if info.reserved_space.needs_scroll_track() {
1092 info.track_bounds.contains(position)
1093 } else {
1094 info.thumb_bounds.contains(position)
1095 }
1096 })
1097 }
1098}
1099
1100impl PartialEq for ScrollbarPrepaintState {
1101 fn eq(&self, other: &Self) -> bool {
1102 self.thumbs == other.thumbs
1103 }
1104}
1105
1106impl<T: ScrollableHandle> Element for ScrollbarElement<T> {
1107 type RequestLayoutState = ();
1108 type PrepaintState = Option<(ScrollbarPrepaintState, Option<f32>)>;
1109
1110 fn id(&self) -> Option<ElementId> {
1111 Some(("scrollbar_animation", self.state.entity_id()).into())
1112 }
1113
1114 fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
1115 None
1116 }
1117
1118 fn request_layout(
1119 &mut self,
1120 _id: Option<&GlobalElementId>,
1121 _inspector_id: Option<&gpui::InspectorElementId>,
1122 window: &mut Window,
1123 cx: &mut App,
1124 ) -> (LayoutId, Self::RequestLayoutState) {
1125 let scrollbar_style = Style {
1126 position: Position::Absolute,
1127 inset: Edges::default(),
1128 size: size(relative(1.), relative(1.)).map(Into::into),
1129 ..Default::default()
1130 };
1131
1132 (window.request_layout(scrollbar_style, None, cx), ())
1133 }
1134
1135 fn prepaint(
1136 &mut self,
1137 id: Option<&GlobalElementId>,
1138 _inspector_id: Option<&gpui::InspectorElementId>,
1139 bounds: Bounds<Pixels>,
1140 _request_layout: &mut Self::RequestLayoutState,
1141 window: &mut Window,
1142 cx: &mut App,
1143 ) -> Self::PrepaintState {
1144 let prepaint_state =
1145 self.state
1146 .read(cx)
1147 .disabled()
1148 .not()
1149 .then(|| ScrollbarPrepaintState {
1150 thumbs: {
1151 let state = self.state.read(cx);
1152 let thumb_ranges = state.thumb_ranges().collect::<SmallVec<[_; 2]>>();
1153 let width = state.style.to_pixels();
1154 let track_color = state.track_color.as_ref();
1155
1156 let additional_padding = if thumb_ranges.len() == 2 {
1157 width
1158 } else {
1159 Pixels::ZERO
1160 };
1161
1162 thumb_ranges
1163 .into_iter()
1164 .map(|(axis, thumb_range, reserved_space)| {
1165 let track_anchor = match axis {
1166 ScrollbarAxis::Horizontal => Anchor::BottomLeft,
1167 ScrollbarAxis::Vertical => Anchor::TopRight,
1168 };
1169
1170 let scroll_track_bounds = Bounds::from_anchor_and_size(
1171 track_anchor,
1172 self.origin + bounds.corner(track_anchor),
1173 bounds.size.apply_along(axis.invert(), |_| {
1174 width
1175 + match state.style {
1176 ScrollbarStyle::Regular => 2 * SCROLLBAR_PADDING,
1177 ScrollbarStyle::Editor => Pixels::ZERO,
1178 }
1179 }),
1180 );
1181
1182 let has_border =
1183 track_color.is_some_and(|track_colors| track_colors.has_border);
1184
1185 let thumb_container_bounds = match state.style {
1188 ScrollbarStyle::Regular => {
1189 scroll_track_bounds.dilate(-SCROLLBAR_PADDING)
1190 }
1191 ScrollbarStyle::Editor if has_border => scroll_track_bounds
1192 .extend(match axis {
1193 ScrollbarAxis::Horizontal => Edges {
1194 top: -BORDER_WIDTH,
1195 ..Default::default()
1196 },
1197
1198 ScrollbarAxis::Vertical => Edges {
1199 left: -BORDER_WIDTH,
1200 ..Default::default()
1201 },
1202 }),
1203 ScrollbarStyle::Editor => scroll_track_bounds,
1204 };
1205
1206 let available_space =
1207 thumb_container_bounds.size.along(axis) - additional_padding;
1208
1209 let thumb_offset = thumb_range.start * available_space;
1210 let thumb_end = thumb_range.end * available_space;
1211 let thumb_bounds = Bounds::new(
1212 thumb_container_bounds
1213 .origin
1214 .apply_along(axis, |origin| origin + thumb_offset),
1215 thumb_container_bounds
1216 .size
1217 .apply_along(axis, |_| thumb_end - thumb_offset),
1218 );
1219
1220 let needs_scroll_track = reserved_space.needs_scroll_track();
1221
1222 ScrollbarLayout {
1223 thumb_bounds,
1224 track_bounds: thumb_container_bounds,
1225 axis,
1226 cursor_hitbox: window.insert_hitbox(
1227 if needs_scroll_track {
1228 if has_border && state.style == ScrollbarStyle::Editor {
1229 scroll_track_bounds
1230 } else {
1231 thumb_container_bounds
1232 }
1233 } else {
1234 thumb_bounds
1235 },
1236 HitboxBehavior::BlockMouseExceptScroll,
1237 ),
1238 track_config: track_color
1239 .filter(|_| needs_scroll_track)
1240 .map(|color| (scroll_track_bounds, color.clone())),
1241 reserved_space,
1242 }
1243 })
1244 .collect()
1245 },
1246 parent_bounds_hitbox: window.insert_hitbox(bounds, HitboxBehavior::Normal),
1247 });
1248 if prepaint_state
1249 .as_ref()
1250 .is_some_and(|state| Some(state) != self.state.read(cx).last_prepaint_state.as_ref())
1251 {
1252 self.state
1253 .update(cx, |state, cx| state.show_scrollbars(window, cx));
1254 }
1255
1256 prepaint_state.map(|state| {
1257 let autohide_delta = self
1258 .state
1259 .read(cx)
1260 .show_state
1261 .animation_state()
1262 .map(|state| match state {
1263 AnimationState::InProgress {
1264 current_delta,
1265 animation_duration: delta_duration,
1266 showing: should_invert,
1267 } => window.with_element_state(id.unwrap(), |state, window| {
1268 let state = state.unwrap_or_else(|| Instant::now());
1269 let current = Instant::now();
1270
1271 let new_delta = DELTA_MAX.min(
1272 current_delta + (current - state).div_duration_f32(delta_duration),
1273 );
1274 self.state.update(cx, |state, _| {
1275 let has_border = state
1276 .track_color
1277 .as_ref()
1278 .is_some_and(|track_colors| track_colors.has_border);
1279 state.show_state.set_delta(new_delta, has_border)
1280 });
1281
1282 window.request_animation_frame();
1283 let delta = if should_invert {
1284 DELTA_MAX - current_delta
1285 } else {
1286 current_delta
1287 };
1288 (ease_in_out(delta), current)
1289 }),
1290 AnimationState::Stale => 1.0,
1291 });
1292
1293 (state, autohide_delta)
1294 })
1295 }
1296
1297 fn paint(
1298 &mut self,
1299 _id: Option<&GlobalElementId>,
1300 _inspector_id: Option<&gpui::InspectorElementId>,
1301 Bounds { origin, size }: Bounds<Pixels>,
1302 _request_layout: &mut Self::RequestLayoutState,
1303 prepaint_state: &mut Self::PrepaintState,
1304 window: &mut Window,
1305 cx: &mut App,
1306 ) {
1307 let Some((prepaint_state, autohide_fade)) = prepaint_state.take() else {
1308 return;
1309 };
1310
1311 let bounds = Bounds::new(self.origin + origin, size);
1312 window.with_content_mask(Some(ContentMask { bounds }), |window| {
1313 let colors = cx.theme().colors();
1314
1315 let capture_phase;
1316
1317 if self.state.read(cx).visible() {
1318 let state = self.state.read(cx);
1319 let thumb_state = &state.thumb_state;
1320 let style = state.style;
1321
1322 if thumb_state.is_dragging() {
1323 capture_phase = DispatchPhase::Capture;
1324 } else {
1325 capture_phase = DispatchPhase::Bubble;
1326 }
1327
1328 for ScrollbarLayout {
1329 thumb_bounds,
1330 cursor_hitbox,
1331 axis,
1332 reserved_space,
1333 track_config,
1334 ..
1335 } in &prepaint_state.thumbs
1336 {
1337 const MAXIMUM_OPACITY: f32 = 0.7;
1338 let (thumb_base_color, hovered) = match thumb_state {
1339 ThumbState::Dragging(dragged_axis, _) if dragged_axis == axis => {
1340 (colors.scrollbar_thumb_active_background, false)
1341 }
1342 ThumbState::Hover(hovered_axis) if hovered_axis == axis => {
1343 (colors.scrollbar_thumb_hover_background, true)
1344 }
1345 _ => (colors.scrollbar_thumb_background, false),
1346 };
1347
1348 let blend_color = track_config
1349 .as_ref()
1350 .map(|(_, colors)| colors.background)
1351 .unwrap_or(colors.surface_background);
1352
1353 let blending_color = if hovered || reserved_space.needs_scroll_track() {
1354 blend_color
1355 } else {
1356 blend_color.min(blend_color.alpha(MAXIMUM_OPACITY))
1357 };
1358
1359 let mut thumb_color = blending_color.blend(thumb_base_color);
1360
1361 if !hovered && let Some(fade) = autohide_fade {
1362 thumb_color.fade_out(fade);
1363 }
1364
1365 if let Some((track_bounds, colors)) = track_config {
1366 let has_border = colors.has_border;
1367
1368 let mut track_color = colors.background;
1369 if let Some(fade) = autohide_fade
1370 && !has_border
1371 {
1372 track_color.fade_out(fade);
1373 }
1374
1375 let border_edges = has_border
1376 .then(|| match axis {
1377 ScrollbarAxis::Horizontal => Edges {
1378 top: BORDER_WIDTH,
1379 ..Default::default()
1380 },
1381 ScrollbarAxis::Vertical => Edges {
1382 left: BORDER_WIDTH,
1383 ..Default::default()
1384 },
1385 })
1386 .unwrap_or_default();
1387
1388 let border_color = if has_border {
1389 cx.theme().colors().border_variant.opacity(0.6)
1390 } else {
1391 Hsla::transparent_black()
1392 };
1393
1394 window.paint_quad(quad(
1395 *track_bounds,
1396 Corners::default(),
1397 track_color,
1398 border_edges,
1399 border_color,
1400 BorderStyle::Solid,
1401 ));
1402 }
1403
1404 window.paint_quad(quad(
1405 *thumb_bounds,
1406 match style {
1407 ScrollbarStyle::Regular => Corners::all(Pixels::MAX)
1408 .clamp_radii_for_quad_size(thumb_bounds.size),
1409 ScrollbarStyle::Editor => Corners::default(),
1410 },
1411 thumb_color,
1412 Edges::default(),
1413 Hsla::transparent_black(),
1414 BorderStyle::default(),
1415 ));
1416
1417 if thumb_state.is_dragging() {
1418 window.set_window_cursor_style(CursorStyle::Arrow);
1419 } else {
1420 window.set_cursor_style(CursorStyle::Arrow, cursor_hitbox);
1421 }
1422 }
1423 } else {
1424 capture_phase = DispatchPhase::Bubble;
1425 }
1426
1427 self.state.update(cx, |state, _| {
1428 state.last_prepaint_state = Some(prepaint_state)
1429 });
1430
1431 window.on_mouse_event({
1432 let state = self.state.clone();
1433
1434 move |event: &MouseDownEvent, phase, window, cx| {
1435 state.update(cx, |state, cx| {
1436 let Some(scrollbar_layout) = (phase == capture_phase
1437 && event.button == MouseButton::Left)
1438 .then(|| state.hit_for_position(&event.position))
1439 .flatten()
1440 else {
1441 return;
1442 };
1443
1444 let ScrollbarLayout {
1445 thumb_bounds, axis, ..
1446 } = scrollbar_layout;
1447
1448 if thumb_bounds.contains(&event.position) {
1449 let offset =
1450 event.position.along(*axis) - thumb_bounds.origin.along(*axis);
1451 state.set_dragging(*axis, offset, window, cx);
1452 } else {
1453 let scroll_handle = state.scroll_handle();
1454 let click_offset = scrollbar_layout.compute_click_offset(
1455 event.position,
1456 scroll_handle.max_offset(),
1457 ScrollbarMouseEvent::TrackClick,
1458 );
1459 state.set_offset(
1460 scroll_handle.offset().apply_along(*axis, |_| click_offset),
1461 cx,
1462 );
1463 };
1464
1465 cx.stop_propagation();
1466 });
1467 }
1468 });
1469
1470 window.on_mouse_event({
1471 let state = self.state.clone();
1472
1473 move |event: &ScrollWheelEvent, phase, window, cx| {
1474 state.update(cx, |state, cx| {
1475 if phase.capture() && state.parent_hovered(window) {
1476 state.update_hovered_thumb(&event.position, window, cx)
1477 }
1478 });
1479 }
1480 });
1481
1482 window.on_mouse_event({
1483 let state = self.state.clone();
1484
1485 move |event: &MouseMoveEvent, phase, window, cx| {
1486 if phase != capture_phase {
1487 return;
1488 }
1489
1490 match state.read(cx).thumb_state {
1491 ThumbState::Dragging(axis, drag_state) if event.dragging() => {
1492 if let Some(scrollbar_layout) = state.read(cx).thumb_for_axis(axis) {
1493 let scroll_handle = state.read(cx).scroll_handle();
1494 let drag_offset = scrollbar_layout.compute_click_offset(
1495 event.position,
1496 scroll_handle.max_offset(),
1497 ScrollbarMouseEvent::ThumbDrag(drag_state),
1498 );
1499 let new_offset =
1500 scroll_handle.offset().apply_along(axis, |_| drag_offset);
1501
1502 state.update(cx, |state, cx| state.set_offset(new_offset, cx));
1503 cx.stop_propagation();
1504 }
1505 }
1506 _ => state.update(cx, |state, cx| {
1507 match state.update_parent_hovered(window) {
1508 hover @ ParentHoverEvent::Entered
1509 | hover @ ParentHoverEvent::Within
1510 if event.pressed_button.is_none() =>
1511 {
1512 if matches!(hover, ParentHoverEvent::Entered) {
1513 state.show_scrollbars(window, cx);
1514 }
1515 state.update_hovered_thumb(&event.position, window, cx);
1516 if state.thumb_state != ThumbState::Inactive {
1517 cx.stop_propagation();
1518 }
1519 }
1520 ParentHoverEvent::Exited => {
1521 state.set_thumb_state(ThumbState::Inactive, window, cx);
1522 }
1523 _ => {}
1524 }
1525 }),
1526 }
1527 }
1528 });
1529
1530 window.on_mouse_event({
1531 let state = self.state.clone();
1532 move |event: &MouseUpEvent, phase, window, cx| {
1533 if phase != capture_phase {
1534 return;
1535 }
1536
1537 state.update(cx, |state, cx| {
1538 if state.is_dragging() {
1539 state.scroll_handle().drag_ended();
1540 }
1541
1542 if !state.parent_hovered(window) {
1543 state.schedule_auto_hide(window, cx);
1544 return;
1545 }
1546
1547 state.update_hovered_thumb(&event.position, window, cx);
1548 });
1549 }
1550 });
1551 })
1552 }
1553}
1554
1555impl<T: ScrollableHandle> IntoElement for ScrollbarElement<T> {
1556 type Element = Self;
1557
1558 fn into_element(self) -> Self::Element {
1559 self
1560 }
1561}