1use std::{cell::Cell, rc::Rc, time::Instant};
7
8use gpui::{
9 fill, point, px, relative, size, App, Axis, Bounds, ContentMask, Corner, CursorStyle, Element,
10 GlobalElementId, Hitbox, HitboxBehavior, Hsla, InspectorElementId, IntoElement, LayoutId,
11 MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, Position, ScrollHandle,
12 ScrollWheelEvent, Size, Style, Window,
13};
14
15use crate::theme::{get_theme_or, Theme};
16
17pub(crate) const WIDTH: Pixels = px(12.0);
19const MIN_THUMB_SIZE: f32 = 48.;
20
21const THUMB_WIDTH: Pixels = px(6.);
22const THUMB_RADIUS: Pixels = px(3.);
23const THUMB_INSET: Pixels = px(3.);
24
25const THUMB_ACTIVE_WIDTH: Pixels = px(8.);
26const THUMB_ACTIVE_RADIUS: Pixels = px(4.);
27const THUMB_ACTIVE_INSET: Pixels = px(2.);
28
29const FADE_OUT_DURATION: f32 = 3.0;
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum ScrollbarAxis {
34 Vertical,
36 Horizontal,
38 Both,
40}
41
42impl ScrollbarAxis {
43 pub fn is_vertical(&self) -> bool {
45 matches!(self, Self::Vertical)
46 }
47
48 pub fn is_horizontal(&self) -> bool {
50 matches!(self, Self::Horizontal)
51 }
52
53 pub fn is_both(&self) -> bool {
55 matches!(self, Self::Both)
56 }
57
58 #[inline]
60 pub fn has_vertical(&self) -> bool {
61 matches!(self, Self::Vertical | Self::Both)
62 }
63
64 #[inline]
66 pub fn has_horizontal(&self) -> bool {
67 matches!(self, Self::Horizontal | Self::Both)
68 }
69
70 #[inline]
71 fn all(&self) -> Vec<Axis> {
72 match self {
73 Self::Vertical => vec![Axis::Vertical],
74 Self::Horizontal => vec![Axis::Horizontal],
75 Self::Both => vec![Axis::Horizontal, Axis::Vertical],
76 }
77 }
78}
79
80#[derive(Debug, Clone)]
82pub struct ScrollbarState(pub(crate) Rc<Cell<ScrollbarStateInner>>);
83
84#[derive(Debug, Clone, Copy)]
86pub struct ScrollbarStateInner {
87 pub(crate) hovered_on_thumb: Option<Axis>,
88 pub(crate) dragged_axis: Option<Axis>,
89 pub(crate) drag_pos: Point<Pixels>,
90 pub(crate) last_scroll_offset: Point<Pixels>,
91 pub(crate) last_scroll_time: Option<Instant>,
92 pub(crate) last_update: Instant,
93}
94
95impl Default for ScrollbarState {
96 fn default() -> Self {
97 Self(Rc::new(Cell::new(ScrollbarStateInner {
98 hovered_on_thumb: None,
99 dragged_axis: None,
100 drag_pos: point(px(0.), px(0.)),
101 last_scroll_offset: point(px(0.), px(0.)),
102 last_scroll_time: None,
103 last_update: Instant::now(),
104 })))
105 }
106}
107
108impl ScrollbarState {
109 #[allow(dead_code)]
111 pub fn init_visible(&self) {
112 let inner = self.0.get();
113 self.0
114 .set(inner.with_last_scroll(inner.last_scroll_offset, Some(Instant::now())));
115 }
116}
117
118impl ScrollbarStateInner {
119 fn with_drag_pos(&self, axis: Axis, pos: Point<Pixels>) -> Self {
120 let mut state = *self;
121 if axis == Axis::Vertical {
122 state.drag_pos.y = pos.y;
123 } else {
124 state.drag_pos.x = pos.x;
125 }
126 state.dragged_axis = Some(axis);
127 state
128 }
129
130 fn with_unset_drag_pos(&self) -> Self {
131 let mut state = *self;
132 state.dragged_axis = None;
133 state
134 }
135
136 fn with_hovered_on_thumb(&self, axis: Option<Axis>) -> Self {
137 let mut state = *self;
138 state.hovered_on_thumb = axis;
139 if axis.is_some() {
140 state.last_scroll_time = Some(Instant::now());
141 }
142 state
143 }
144
145 fn with_last_scroll(
146 &self,
147 last_scroll_offset: Point<Pixels>,
148 last_scroll_time: Option<Instant>,
149 ) -> Self {
150 let mut state = *self;
151 state.last_scroll_offset = last_scroll_offset;
152 state.last_scroll_time = last_scroll_time;
153 state
154 }
155
156 fn with_last_update(&self, t: Instant) -> Self {
157 let mut state = *self;
158 state.last_update = t;
159 state
160 }
161
162 fn is_scrollbar_visible(&self) -> bool {
163 if self.dragged_axis.is_some() {
164 return true;
165 }
166
167 if let Some(last_time) = self.last_scroll_time {
168 let elapsed = Instant::now().duration_since(last_time).as_secs_f32();
169 elapsed < FADE_OUT_DURATION
170 } else {
171 false
172 }
173 }
174}
175
176pub struct Scrollbar {
178 axis: ScrollbarAxis,
179 scroll_handle: ScrollHandle,
180 state: ScrollbarState,
181 scroll_size: Option<Size<Pixels>>,
182 always_visible: bool,
183 horizontal_at_top: bool,
184 custom_theme: Option<Theme>,
185}
186
187impl Scrollbar {
188 pub fn new(axis: ScrollbarAxis, state: &ScrollbarState, scroll_handle: &ScrollHandle) -> Self {
190 Self {
191 state: state.clone(),
192 axis,
193 scroll_handle: scroll_handle.clone(),
194 scroll_size: None,
195 always_visible: false,
196 horizontal_at_top: false,
197 custom_theme: None,
198 }
199 }
200
201 #[allow(dead_code)]
203 pub fn vertical(state: &ScrollbarState, scroll_handle: &ScrollHandle) -> Self {
204 Self::new(ScrollbarAxis::Vertical, state, scroll_handle)
205 }
206
207 #[allow(dead_code)]
209 pub fn horizontal(state: &ScrollbarState, scroll_handle: &ScrollHandle) -> Self {
210 Self::new(ScrollbarAxis::Horizontal, state, scroll_handle)
211 }
212
213 #[allow(dead_code)]
215 pub fn both(state: &ScrollbarState, scroll_handle: &ScrollHandle) -> Self {
216 Self::new(ScrollbarAxis::Both, state, scroll_handle)
217 }
218
219 #[must_use]
221 pub fn always_visible(mut self) -> Self {
222 self.always_visible = true;
223 self
224 }
225
226 #[must_use]
228 #[allow(dead_code)]
229 pub fn axis(mut self, axis: ScrollbarAxis) -> Self {
230 self.axis = axis;
231 self
232 }
233
234 #[must_use]
236 #[allow(dead_code)]
237 pub fn horizontal_top(mut self) -> Self {
238 self.horizontal_at_top = true;
239 self
240 }
241
242 #[must_use]
244 #[allow(dead_code)]
245 pub fn scroll_size(mut self, scroll_size: Size<Pixels>) -> Self {
246 self.scroll_size = Some(scroll_size);
247 self
248 }
249
250 #[must_use]
252 pub fn theme(mut self, theme: Theme) -> Self {
253 self.custom_theme = Some(theme);
254 self
255 }
256
257 fn get_thumb_color(&self, cx: &App) -> Hsla {
258 let theme = get_theme_or(cx, self.custom_theme.as_ref());
259 gpui::rgb(theme.text_muted).into()
260 }
261
262 fn get_track_color(&self, cx: &App) -> Hsla {
263 let theme = get_theme_or(cx, self.custom_theme.as_ref());
264 let mut color: Hsla = gpui::rgb(theme.bg_input).into();
265 color.a = 0.3;
266 color
267 }
268
269 fn get_hover_thumb_color(&self, cx: &App) -> Hsla {
270 let theme = get_theme_or(cx, self.custom_theme.as_ref());
271 let mut color: Hsla = gpui::rgb(theme.text_muted).into();
272 color.a = 0.8;
273 color
274 }
275}
276
277impl IntoElement for Scrollbar {
278 type Element = Self;
279
280 fn into_element(self) -> Self::Element {
281 self
282 }
283}
284
285pub struct AxisPrepaintState {
287 axis: Axis,
288 bar_hitbox: Hitbox,
289 bounds: Bounds<Pixels>,
290 radius: Pixels,
291 bg: Hsla,
292 thumb_bounds: Bounds<Pixels>,
293 thumb_fill_bounds: Bounds<Pixels>,
294 thumb_bg: Hsla,
295 scroll_size: Pixels,
296 container_size: Pixels,
297 thumb_size: Pixels,
298 margin_end: Pixels,
299}
300
301pub struct PrepaintState {
303 hitbox: Hitbox,
304 states: Vec<AxisPrepaintState>,
305}
306
307impl Element for Scrollbar {
308 type RequestLayoutState = ();
309 type PrepaintState = PrepaintState;
310
311 fn id(&self) -> Option<gpui::ElementId> {
312 None
313 }
314
315 fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
316 None
317 }
318
319 fn request_layout(
320 &mut self,
321 _: Option<&GlobalElementId>,
322 _: Option<&InspectorElementId>,
323 window: &mut Window,
324 cx: &mut App,
325 ) -> (LayoutId, Self::RequestLayoutState) {
326 let mut style = Style {
327 position: Position::Absolute,
328 flex_grow: 1.0,
329 flex_shrink: 1.0,
330 ..Default::default()
331 };
332 style.size.width = relative(1.).into();
333 style.size.height = relative(1.).into();
334
335 (window.request_layout(style, None, cx), ())
336 }
337
338 fn prepaint(
339 &mut self,
340 _: Option<&GlobalElementId>,
341 _: Option<&InspectorElementId>,
342 bounds: Bounds<Pixels>,
343 _: &mut Self::RequestLayoutState,
344 window: &mut Window,
345 cx: &mut App,
346 ) -> Self::PrepaintState {
347 let hitbox = window.with_content_mask(Some(ContentMask { bounds }), |window| {
348 window.insert_hitbox(bounds, HitboxBehavior::Normal)
349 });
350
351 let mut states = vec![];
352 let mut has_both = self.axis.is_both();
353
354 let scroll_size = self
355 .scroll_size
356 .unwrap_or_else(|| self.scroll_handle.max_offset() + self.scroll_handle.bounds().size);
357
358 for axis in self.axis.all().into_iter() {
359 let is_vertical = axis == Axis::Vertical;
360 let (scroll_area_size, container_size, scroll_position) = if is_vertical {
361 (
362 scroll_size.height,
363 hitbox.size.height,
364 self.scroll_handle.offset().y,
365 )
366 } else {
367 (
368 scroll_size.width,
369 hitbox.size.width,
370 self.scroll_handle.offset().x,
371 )
372 };
373
374 let margin_end = if has_both && !is_vertical {
375 WIDTH
376 } else {
377 px(0.)
378 };
379
380 if scroll_area_size <= container_size {
381 has_both = false;
382 continue;
383 }
384
385 let thumb_length =
386 (container_size / scroll_area_size * container_size).max(px(MIN_THUMB_SIZE));
387 let thumb_start = -(scroll_position / (scroll_area_size - container_size)
388 * (container_size - margin_end - thumb_length));
389 let thumb_end = (thumb_start + thumb_length).min(container_size - margin_end);
390
391 let bounds = Bounds {
392 origin: if is_vertical {
393 point(hitbox.origin.x + hitbox.size.width - WIDTH, hitbox.origin.y)
394 } else if self.horizontal_at_top {
395 point(hitbox.origin.x, hitbox.origin.y)
396 } else {
397 point(
398 hitbox.origin.x,
399 hitbox.origin.y + hitbox.size.height - WIDTH,
400 )
401 },
402 size: size(
403 if is_vertical { WIDTH } else { hitbox.size.width },
404 if is_vertical { hitbox.size.height } else { WIDTH },
405 ),
406 };
407
408 let state_inner = self.state.0.get();
409 let is_hovered_on_thumb = state_inner.hovered_on_thumb == Some(axis);
410 let is_dragged = state_inner.dragged_axis == Some(axis);
411
412 let (thumb_bg, track_bg, _thumb_width, inset, radius) =
413 if is_dragged || is_hovered_on_thumb {
414 (
415 self.get_hover_thumb_color(cx),
416 self.get_track_color(cx),
417 THUMB_ACTIVE_WIDTH,
418 THUMB_ACTIVE_INSET,
419 THUMB_ACTIVE_RADIUS,
420 )
421 } else {
422 (
423 self.get_thumb_color(cx),
424 self.get_track_color(cx),
425 THUMB_WIDTH,
426 THUMB_INSET,
427 THUMB_RADIUS,
428 )
429 };
430
431 let thumb_length = thumb_end - thumb_start - inset * 2;
432 let thumb_bounds = if is_vertical {
433 Bounds::from_corner_and_size(
434 Corner::TopRight,
435 bounds.top_right() + point(-inset, inset + thumb_start),
436 size(WIDTH, thumb_length),
437 )
438 } else if self.horizontal_at_top {
439 Bounds::from_corner_and_size(
440 Corner::TopLeft,
441 bounds.origin + point(inset + thumb_start, inset),
442 size(thumb_length, WIDTH),
443 )
444 } else {
445 Bounds::from_corner_and_size(
446 Corner::BottomLeft,
447 bounds.bottom_left() + point(inset + thumb_start, -inset),
448 size(thumb_length, WIDTH),
449 )
450 };
451
452 let thumb_fill_bounds = if is_vertical {
453 Bounds::from_corner_and_size(
454 Corner::TopRight,
455 bounds.top_right() + point(-inset, inset + thumb_start),
456 size(THUMB_WIDTH, thumb_length),
457 )
458 } else if self.horizontal_at_top {
459 Bounds::from_corner_and_size(
460 Corner::TopLeft,
461 bounds.origin + point(inset + thumb_start, inset),
462 size(thumb_length, THUMB_WIDTH),
463 )
464 } else {
465 Bounds::from_corner_and_size(
466 Corner::BottomLeft,
467 bounds.bottom_left() + point(inset + thumb_start, -inset),
468 size(thumb_length, THUMB_WIDTH),
469 )
470 };
471
472 let bar_hitbox = window.with_content_mask(Some(ContentMask { bounds }), |window| {
473 window.insert_hitbox(bounds, HitboxBehavior::Normal)
474 });
475
476 states.push(AxisPrepaintState {
477 axis,
478 bar_hitbox,
479 bounds,
480 radius,
481 bg: track_bg,
482 thumb_bounds,
483 thumb_fill_bounds,
484 thumb_bg,
485 scroll_size: scroll_area_size,
486 container_size,
487 thumb_size: thumb_length,
488 margin_end,
489 })
490 }
491
492 PrepaintState { hitbox, states }
493 }
494
495 fn paint(
496 &mut self,
497 _: Option<&GlobalElementId>,
498 _: Option<&InspectorElementId>,
499 _: Bounds<Pixels>,
500 _: &mut Self::RequestLayoutState,
501 prepaint: &mut Self::PrepaintState,
502 window: &mut Window,
503 _cx: &mut App,
504 ) {
505 let view_id = window.current_view();
506 let hitbox_bounds = prepaint.hitbox.bounds;
507 let is_visible = self.state.0.get().is_scrollbar_visible() || self.always_visible;
508
509 if self.scroll_handle.offset() != self.state.0.get().last_scroll_offset {
510 self.state.0.set(
511 self.state
512 .0
513 .get()
514 .with_last_scroll(self.scroll_handle.offset(), Some(Instant::now())),
515 );
516 }
519
520 if !is_visible && !self.always_visible {
521 return;
522 }
523
524 window.with_content_mask(
525 Some(ContentMask {
526 bounds: hitbox_bounds,
527 }),
528 |window| {
529 for state in prepaint.states.iter() {
530 let axis = state.axis;
531 let radius = state.radius;
532 let bounds = state.bounds;
533 let thumb_bounds = state.thumb_bounds;
534 let scroll_area_size = state.scroll_size;
535 let container_size = state.container_size;
536 let thumb_size = state.thumb_size;
537 let margin_end = state.margin_end;
538 let is_vertical = axis == Axis::Vertical;
539
540 window.set_cursor_style(CursorStyle::default(), &state.bar_hitbox);
541
542 window.paint_layer(hitbox_bounds, |cx| {
543 cx.paint_quad(fill(state.bounds, state.bg));
544
545 cx.paint_quad(
546 fill(state.thumb_fill_bounds, state.thumb_bg).corner_radii(radius),
547 );
548 });
549
550 window.on_mouse_event({
551 let state = self.state.clone();
552 let scroll_handle = self.scroll_handle.clone();
553
554 move |event: &ScrollWheelEvent, phase, _hitbox, cx| {
555 if phase.bubble() && hitbox_bounds.contains(&event.position)
556 && scroll_handle.offset() != state.0.get().last_scroll_offset {
557 state.0.set(state.0.get().with_last_scroll(
558 scroll_handle.offset(),
559 Some(Instant::now()),
560 ));
561 cx.notify(view_id);
562 }
563 }
564 });
565
566 let safe_range = (-scroll_area_size + container_size)..px(0.);
567
568 window.on_mouse_event({
569 let state = self.state.clone();
570 let scroll_handle = self.scroll_handle.clone();
571
572 move |event: &MouseDownEvent, phase, _hitbox, cx| {
573 if phase.bubble() && bounds.contains(&event.position) {
574 cx.stop_propagation();
575
576 if thumb_bounds.contains(&event.position) {
577 let pos = event.position - thumb_bounds.origin;
578 state.0.set(state.0.get().with_drag_pos(axis, pos));
579 cx.notify(view_id);
580 } else {
581 let offset = scroll_handle.offset();
582 let percentage = if is_vertical {
583 (event.position.y - thumb_size / 2. - bounds.origin.y)
584 / (bounds.size.height - thumb_size)
585 } else {
586 (event.position.x - thumb_size / 2. - bounds.origin.x)
587 / (bounds.size.width - thumb_size)
588 }
589 .min(1.);
590
591 if is_vertical {
592 scroll_handle.set_offset(point(
593 offset.x,
594 (-scroll_area_size * percentage)
595 .clamp(safe_range.start, safe_range.end),
596 ));
597 } else {
598 scroll_handle.set_offset(point(
599 (-scroll_area_size * percentage)
600 .clamp(safe_range.start, safe_range.end),
601 offset.y,
602 ));
603 }
604 }
605 }
606 }
607 });
608
609 window.on_mouse_event({
610 let scroll_handle = self.scroll_handle.clone();
611 let state = self.state.clone();
612
613 move |event: &MouseMoveEvent, _phase, _hitbox, cx| {
614 let mut notify = false;
615
616 if thumb_bounds.contains(&event.position) {
617 if state.0.get().hovered_on_thumb != Some(axis) {
618 state.0.set(state.0.get().with_hovered_on_thumb(Some(axis)));
619 notify = true;
620 }
621 } else if state.0.get().hovered_on_thumb == Some(axis) {
622 state.0.set(state.0.get().with_hovered_on_thumb(None));
623 notify = true;
624 }
625
626 if state.0.get().dragged_axis == Some(axis) && event.dragging() {
627 let drag_pos = state.0.get().drag_pos;
628
629 let percentage = (if is_vertical {
630 (event.position.y - drag_pos.y - bounds.origin.y)
631 / (bounds.size.height - thumb_size)
632 } else {
633 (event.position.x - drag_pos.x - bounds.origin.x)
634 / (bounds.size.width - thumb_size - margin_end)
635 })
636 .clamp(0., 1.);
637
638 let offset = if is_vertical {
639 point(
640 scroll_handle.offset().x,
641 (-(scroll_area_size - container_size) * percentage)
642 .clamp(safe_range.start, safe_range.end),
643 )
644 } else {
645 point(
646 (-(scroll_area_size - container_size) * percentage)
647 .clamp(safe_range.start, safe_range.end),
648 scroll_handle.offset().y,
649 )
650 };
651
652 if (scroll_handle.offset().y - offset.y).abs() > px(1.)
653 || (scroll_handle.offset().x - offset.x).abs() > px(1.)
654 {
655 scroll_handle.set_offset(offset);
656 state.0.set(state.0.get().with_last_update(Instant::now()));
657 notify = true;
658 }
659 }
660
661 if notify {
662 cx.notify(view_id);
663 }
664 }
665 });
666
667 window.on_mouse_event({
668 let state = self.state.clone();
669
670 move |_event: &MouseUpEvent, phase, _hitbox, cx| {
671 if phase.bubble() {
672 state.0.set(state.0.get().with_unset_drag_pos());
673 cx.notify(view_id);
674 }
675 }
676 });
677 }
678 },
679 );
680 }
681}