scrollbar.rs

  1use std::{
  2    any::Any,
  3    cell::{Cell, RefCell},
  4    fmt::Debug,
  5    ops::Range,
  6    rc::Rc,
  7    sync::Arc,
  8    time::Duration,
  9};
 10
 11use crate::{IntoElement, prelude::*, px, relative};
 12use gpui::{
 13    Along, App, Axis as ScrollbarAxis, BorderStyle, Bounds, ContentMask, Corners, CursorStyle,
 14    Edges, Element, ElementId, Entity, EntityId, GlobalElementId, Hitbox, HitboxBehavior, Hsla,
 15    IsZero, LayoutId, ListState, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels,
 16    Point, ScrollHandle, ScrollWheelEvent, Size, Style, Task, UniformListScrollHandle, Window,
 17    quad,
 18};
 19
 20pub struct Scrollbar {
 21    thumb: Range<f32>,
 22    state: ScrollbarState,
 23    kind: ScrollbarAxis,
 24}
 25
 26#[derive(Default, Debug, Clone, Copy)]
 27enum ThumbState {
 28    #[default]
 29    Inactive,
 30    Hover,
 31    Dragging(Pixels),
 32}
 33
 34impl ThumbState {
 35    fn is_dragging(&self) -> bool {
 36        matches!(*self, ThumbState::Dragging(_))
 37    }
 38}
 39
 40impl ScrollableHandle for UniformListScrollHandle {
 41    fn max_offset(&self) -> Size<Pixels> {
 42        self.0.borrow().base_handle.max_offset()
 43    }
 44
 45    fn set_offset(&self, point: Point<Pixels>) {
 46        self.0.borrow().base_handle.set_offset(point);
 47    }
 48
 49    fn offset(&self) -> Point<Pixels> {
 50        self.0.borrow().base_handle.offset()
 51    }
 52
 53    fn viewport(&self) -> Bounds<Pixels> {
 54        self.0.borrow().base_handle.bounds()
 55    }
 56}
 57
 58impl ScrollableHandle for ListState {
 59    fn max_offset(&self) -> Size<Pixels> {
 60        self.max_offset_for_scrollbar()
 61    }
 62
 63    fn set_offset(&self, point: Point<Pixels>) {
 64        self.set_offset_from_scrollbar(point);
 65    }
 66
 67    fn offset(&self) -> Point<Pixels> {
 68        self.scroll_px_offset_for_scrollbar()
 69    }
 70
 71    fn drag_started(&self) {
 72        self.scrollbar_drag_started();
 73    }
 74
 75    fn drag_ended(&self) {
 76        self.scrollbar_drag_ended();
 77    }
 78
 79    fn viewport(&self) -> Bounds<Pixels> {
 80        self.viewport_bounds()
 81    }
 82}
 83
 84impl ScrollableHandle for ScrollHandle {
 85    fn max_offset(&self) -> Size<Pixels> {
 86        self.max_offset()
 87    }
 88
 89    fn set_offset(&self, point: Point<Pixels>) {
 90        self.set_offset(point);
 91    }
 92
 93    fn offset(&self) -> Point<Pixels> {
 94        self.offset()
 95    }
 96
 97    fn viewport(&self) -> Bounds<Pixels> {
 98        self.bounds()
 99    }
100}
101
102pub trait ScrollableHandle: Any + Debug {
103    fn content_size(&self) -> Size<Pixels> {
104        self.viewport().size + self.max_offset()
105    }
106    fn max_offset(&self) -> Size<Pixels>;
107    fn set_offset(&self, point: Point<Pixels>);
108    fn offset(&self) -> Point<Pixels>;
109    fn viewport(&self) -> Bounds<Pixels>;
110    fn drag_started(&self) {}
111    fn drag_ended(&self) {}
112}
113
114/// A scrollbar state that should be persisted across frames.
115#[derive(Clone, Debug)]
116pub struct ScrollbarState {
117    thumb_state: Rc<Cell<ThumbState>>,
118    parent_id: Option<EntityId>,
119    scroll_handle: Arc<dyn ScrollableHandle>,
120    auto_hide: Rc<RefCell<AutoHide>>,
121}
122
123#[derive(Debug)]
124enum AutoHide {
125    Disabled,
126    Hidden {
127        parent_id: EntityId,
128    },
129    Visible {
130        parent_id: EntityId,
131        _task: Task<()>,
132    },
133}
134
135impl AutoHide {
136    fn is_hidden(&self) -> bool {
137        matches!(self, AutoHide::Hidden { .. })
138    }
139}
140
141impl ScrollbarState {
142    pub fn new(scroll: impl ScrollableHandle) -> Self {
143        Self {
144            thumb_state: Default::default(),
145            parent_id: None,
146            scroll_handle: Arc::new(scroll),
147            auto_hide: Rc::new(RefCell::new(AutoHide::Disabled)),
148        }
149    }
150
151    /// Set a parent model which should be notified whenever this Scrollbar gets a scroll event.
152    pub fn parent_entity<V: 'static>(mut self, v: &Entity<V>) -> Self {
153        self.parent_id = Some(v.entity_id());
154        self
155    }
156
157    pub fn scroll_handle(&self) -> &Arc<dyn ScrollableHandle> {
158        &self.scroll_handle
159    }
160
161    pub fn is_dragging(&self) -> bool {
162        matches!(self.thumb_state.get(), ThumbState::Dragging(_))
163    }
164
165    fn set_dragging(&self, drag_offset: Pixels) {
166        self.set_thumb_state(ThumbState::Dragging(drag_offset));
167        self.scroll_handle.drag_started();
168    }
169
170    fn set_thumb_hovered(&self, hovered: bool) {
171        self.set_thumb_state(if hovered {
172            ThumbState::Hover
173        } else {
174            ThumbState::Inactive
175        });
176    }
177
178    fn set_thumb_state(&self, state: ThumbState) {
179        self.thumb_state.set(state);
180    }
181
182    fn thumb_range(&self, axis: ScrollbarAxis) -> Option<Range<f32>> {
183        const MINIMUM_THUMB_SIZE: Pixels = px(25.);
184        let max_offset = self.scroll_handle.max_offset().along(axis);
185        let viewport_size = self.scroll_handle.viewport().size.along(axis);
186        if max_offset.is_zero() || viewport_size.is_zero() {
187            return None;
188        }
189        let content_size = viewport_size + max_offset;
190        let visible_percentage = viewport_size / content_size;
191        let thumb_size = MINIMUM_THUMB_SIZE.max(viewport_size * visible_percentage);
192        if thumb_size > viewport_size {
193            return None;
194        }
195        let current_offset = self
196            .scroll_handle
197            .offset()
198            .along(axis)
199            .clamp(-max_offset, Pixels::ZERO)
200            .abs();
201        let start_offset = (current_offset / max_offset) * (viewport_size - thumb_size);
202        let thumb_percentage_start = start_offset / viewport_size;
203        let thumb_percentage_end = (start_offset + thumb_size) / viewport_size;
204        Some(thumb_percentage_start..thumb_percentage_end)
205    }
206
207    fn show_temporarily(&self, parent_id: EntityId, cx: &mut App) {
208        const SHOW_INTERVAL: Duration = Duration::from_secs(1);
209
210        let auto_hide = self.auto_hide.clone();
211        auto_hide.replace(AutoHide::Visible {
212            parent_id,
213            _task: cx.spawn({
214                let this = auto_hide.clone();
215                async move |cx| {
216                    cx.background_executor().timer(SHOW_INTERVAL).await;
217                    this.replace(AutoHide::Hidden { parent_id });
218                    cx.update(|cx| {
219                        cx.notify(parent_id);
220                    })
221                    .ok();
222                }
223            }),
224        });
225    }
226
227    fn unhide(&self, position: &Point<Pixels>, cx: &mut App) {
228        let parent_id = match &*self.auto_hide.borrow() {
229            AutoHide::Disabled => return,
230            AutoHide::Hidden { parent_id } => *parent_id,
231            AutoHide::Visible { parent_id, _task } => *parent_id,
232        };
233
234        if self.scroll_handle().viewport().contains(position) {
235            self.show_temporarily(parent_id, cx);
236        }
237    }
238}
239
240impl Scrollbar {
241    pub fn vertical(state: ScrollbarState) -> Option<Self> {
242        Self::new(state, ScrollbarAxis::Vertical)
243    }
244
245    pub fn horizontal(state: ScrollbarState) -> Option<Self> {
246        Self::new(state, ScrollbarAxis::Horizontal)
247    }
248
249    fn new(state: ScrollbarState, kind: ScrollbarAxis) -> Option<Self> {
250        let thumb = state.thumb_range(kind)?;
251        Some(Self { thumb, state, kind })
252    }
253
254    /// Automatically hide the scrollbar when idle
255    pub fn auto_hide<V: 'static>(self, cx: &mut Context<V>) -> Self {
256        if matches!(*self.state.auto_hide.borrow(), AutoHide::Disabled) {
257            self.state.show_temporarily(cx.entity_id(), cx);
258        }
259        self
260    }
261}
262
263impl Element for Scrollbar {
264    type RequestLayoutState = ();
265    type PrepaintState = Hitbox;
266
267    fn id(&self) -> Option<ElementId> {
268        None
269    }
270
271    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
272        None
273    }
274
275    fn request_layout(
276        &mut self,
277        _id: Option<&GlobalElementId>,
278        _inspector_id: Option<&gpui::InspectorElementId>,
279        window: &mut Window,
280        cx: &mut App,
281    ) -> (LayoutId, Self::RequestLayoutState) {
282        let mut style = Style::default();
283        style.flex_grow = 1.;
284        style.flex_shrink = 1.;
285
286        if self.kind == ScrollbarAxis::Vertical {
287            style.size.width = px(12.).into();
288            style.size.height = relative(1.).into();
289        } else {
290            style.size.width = relative(1.).into();
291            style.size.height = px(12.).into();
292        }
293
294        (window.request_layout(style, None, cx), ())
295    }
296
297    fn prepaint(
298        &mut self,
299        _id: Option<&GlobalElementId>,
300        _inspector_id: Option<&gpui::InspectorElementId>,
301        bounds: Bounds<Pixels>,
302        _request_layout: &mut Self::RequestLayoutState,
303        window: &mut Window,
304        _: &mut App,
305    ) -> Self::PrepaintState {
306        window.with_content_mask(Some(ContentMask { bounds }), |window| {
307            window.insert_hitbox(bounds, HitboxBehavior::Normal)
308        })
309    }
310
311    fn paint(
312        &mut self,
313        _id: Option<&GlobalElementId>,
314        _inspector_id: Option<&gpui::InspectorElementId>,
315        bounds: Bounds<Pixels>,
316        _request_layout: &mut Self::RequestLayoutState,
317        hitbox: &mut Self::PrepaintState,
318        window: &mut Window,
319        cx: &mut App,
320    ) {
321        const EXTRA_PADDING: Pixels = px(5.0);
322        window.with_content_mask(Some(ContentMask { bounds }), |window| {
323            let axis = self.kind;
324            let colors = cx.theme().colors();
325            let thumb_state = self.state.thumb_state.get();
326            let thumb_base_color = match thumb_state {
327                ThumbState::Dragging(_) => colors.scrollbar_thumb_active_background,
328                ThumbState::Hover => colors.scrollbar_thumb_hover_background,
329                ThumbState::Inactive => colors.scrollbar_thumb_background,
330            };
331
332            let thumb_background = colors.surface_background.blend(thumb_base_color);
333
334            let padded_bounds = Bounds::from_corners(
335                bounds
336                    .origin
337                    .apply_along(axis, |origin| origin + EXTRA_PADDING),
338                bounds
339                    .bottom_right()
340                    .apply_along(axis, |track_end| track_end - 3.0 * EXTRA_PADDING),
341            );
342
343            let thumb_offset = self.thumb.start * padded_bounds.size.along(axis);
344            let thumb_end = self.thumb.end * padded_bounds.size.along(axis);
345
346            let thumb_bounds = Bounds::new(
347                padded_bounds
348                    .origin
349                    .apply_along(axis, |origin| origin + thumb_offset),
350                padded_bounds
351                    .size
352                    .apply_along(axis, |_| thumb_end - thumb_offset)
353                    .apply_along(axis.invert(), |width| width / 1.5),
354            );
355
356            if thumb_state.is_dragging() || !self.state.auto_hide.borrow().is_hidden() {
357                let corners = Corners::all(thumb_bounds.size.along(axis.invert()) / 2.0);
358
359                window.paint_quad(quad(
360                    thumb_bounds,
361                    corners,
362                    thumb_background,
363                    Edges::default(),
364                    Hsla::transparent_black(),
365                    BorderStyle::default(),
366                ));
367            }
368
369            if thumb_state.is_dragging() {
370                window.set_window_cursor_style(CursorStyle::Arrow);
371            } else {
372                window.set_cursor_style(CursorStyle::Arrow, hitbox);
373            }
374
375            enum ScrollbarMouseEvent {
376                GutterClick,
377                ThumbDrag(Pixels),
378            }
379
380            let compute_click_offset =
381                move |event_position: Point<Pixels>,
382                      max_offset: Size<Pixels>,
383                      event_type: ScrollbarMouseEvent| {
384                    let viewport_size = padded_bounds.size.along(axis);
385
386                    let thumb_size = thumb_bounds.size.along(axis);
387
388                    let thumb_offset = match event_type {
389                        ScrollbarMouseEvent::GutterClick => thumb_size / 2.,
390                        ScrollbarMouseEvent::ThumbDrag(thumb_offset) => thumb_offset,
391                    };
392
393                    let thumb_start = (event_position.along(axis)
394                        - padded_bounds.origin.along(axis)
395                        - thumb_offset)
396                        .clamp(px(0.), viewport_size - thumb_size);
397
398                    let max_offset = max_offset.along(axis);
399                    let percentage = if viewport_size > thumb_size {
400                        thumb_start / (viewport_size - thumb_size)
401                    } else {
402                        0.
403                    };
404
405                    -max_offset * percentage
406                };
407
408            window.on_mouse_event({
409                let state = self.state.clone();
410                move |event: &MouseDownEvent, phase, _, _| {
411                    if !phase.bubble()
412                        || event.button != MouseButton::Left
413                        || !bounds.contains(&event.position)
414                    {
415                        return;
416                    }
417
418                    if thumb_bounds.contains(&event.position) {
419                        let offset = event.position.along(axis) - thumb_bounds.origin.along(axis);
420                        state.set_dragging(offset);
421                    } else {
422                        let scroll_handle = state.scroll_handle();
423                        let click_offset = compute_click_offset(
424                            event.position,
425                            scroll_handle.max_offset(),
426                            ScrollbarMouseEvent::GutterClick,
427                        );
428                        scroll_handle
429                            .set_offset(scroll_handle.offset().apply_along(axis, |_| click_offset));
430                    }
431                }
432            });
433
434            window.on_mouse_event({
435                let state = self.state.clone();
436                let scroll_handle = self.state.scroll_handle().clone();
437                move |event: &ScrollWheelEvent, phase, window, cx| {
438                    if phase.bubble() {
439                        state.unhide(&event.position, cx);
440
441                        if bounds.contains(&event.position) {
442                            let current_offset = scroll_handle.offset();
443                            scroll_handle.set_offset(
444                                current_offset + event.delta.pixel_delta(window.line_height()),
445                            );
446                        }
447                    }
448                }
449            });
450
451            window.on_mouse_event({
452                let state = self.state.clone();
453                move |event: &MouseMoveEvent, phase, window, cx| {
454                    if phase.bubble() {
455                        state.unhide(&event.position, cx);
456
457                        match state.thumb_state.get() {
458                            ThumbState::Dragging(drag_state) if event.dragging() => {
459                                let scroll_handle = state.scroll_handle();
460                                let drag_offset = compute_click_offset(
461                                    event.position,
462                                    scroll_handle.max_offset(),
463                                    ScrollbarMouseEvent::ThumbDrag(drag_state),
464                                );
465                                scroll_handle.set_offset(
466                                    scroll_handle.offset().apply_along(axis, |_| drag_offset),
467                                );
468                                window.refresh();
469                                if let Some(id) = state.parent_id {
470                                    cx.notify(id);
471                                }
472                            }
473                            _ if event.pressed_button.is_none() => {
474                                state.set_thumb_hovered(thumb_bounds.contains(&event.position))
475                            }
476                            _ => {}
477                        }
478                    }
479                }
480            });
481
482            window.on_mouse_event({
483                let state = self.state.clone();
484                move |event: &MouseUpEvent, phase, _, cx| {
485                    if phase.bubble() {
486                        if state.is_dragging() {
487                            state.scroll_handle().drag_ended();
488                            if let Some(id) = state.parent_id {
489                                cx.notify(id);
490                            }
491                        }
492                        state.set_thumb_hovered(thumb_bounds.contains(&event.position));
493                    }
494                }
495            });
496        })
497    }
498}
499
500impl IntoElement for Scrollbar {
501    type Element = Self;
502
503    fn into_element(self) -> Self::Element {
504        self
505    }
506}