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(
307            Some(ContentMask {
308                bounds,
309                ..Default::default()
310            }),
311            |window| window.insert_hitbox(bounds, HitboxBehavior::Normal),
312        )
313    }
314
315    fn paint(
316        &mut self,
317        _id: Option<&GlobalElementId>,
318        _inspector_id: Option<&gpui::InspectorElementId>,
319        bounds: Bounds<Pixels>,
320        _request_layout: &mut Self::RequestLayoutState,
321        hitbox: &mut Self::PrepaintState,
322        window: &mut Window,
323        cx: &mut App,
324    ) {
325        const EXTRA_PADDING: Pixels = px(5.0);
326        let content_mask = ContentMask {
327            bounds,
328            ..Default::default()
329        };
330        window.with_content_mask(Some(content_mask), |window| {
331            let axis = self.kind;
332            let colors = cx.theme().colors();
333            let thumb_state = self.state.thumb_state.get();
334            let thumb_base_color = match thumb_state {
335                ThumbState::Dragging(_) => colors.scrollbar_thumb_active_background,
336                ThumbState::Hover => colors.scrollbar_thumb_hover_background,
337                ThumbState::Inactive => colors.scrollbar_thumb_background,
338            };
339
340            let thumb_background = colors.surface_background.blend(thumb_base_color);
341
342            let padded_bounds = Bounds::from_corners(
343                bounds
344                    .origin
345                    .apply_along(axis, |origin| origin + EXTRA_PADDING),
346                bounds
347                    .bottom_right()
348                    .apply_along(axis, |track_end| track_end - 3.0 * EXTRA_PADDING),
349            );
350
351            let thumb_offset = self.thumb.start * padded_bounds.size.along(axis);
352            let thumb_end = self.thumb.end * padded_bounds.size.along(axis);
353
354            let thumb_bounds = Bounds::new(
355                padded_bounds
356                    .origin
357                    .apply_along(axis, |origin| origin + thumb_offset),
358                padded_bounds
359                    .size
360                    .apply_along(axis, |_| thumb_end - thumb_offset)
361                    .apply_along(axis.invert(), |width| width / 1.5),
362            );
363
364            if thumb_state.is_dragging() || !self.state.auto_hide.borrow().is_hidden() {
365                let corners = Corners::all(thumb_bounds.size.along(axis.invert()) / 2.0);
366
367                window.paint_quad(quad(
368                    thumb_bounds,
369                    corners,
370                    thumb_background,
371                    Edges::default(),
372                    Hsla::transparent_black(),
373                    BorderStyle::default(),
374                ));
375            }
376
377            if thumb_state.is_dragging() {
378                window.set_window_cursor_style(CursorStyle::Arrow);
379            } else {
380                window.set_cursor_style(CursorStyle::Arrow, hitbox);
381            }
382
383            enum ScrollbarMouseEvent {
384                GutterClick,
385                ThumbDrag(Pixels),
386            }
387
388            let compute_click_offset =
389                move |event_position: Point<Pixels>,
390                      max_offset: Size<Pixels>,
391                      event_type: ScrollbarMouseEvent| {
392                    let viewport_size = padded_bounds.size.along(axis);
393
394                    let thumb_size = thumb_bounds.size.along(axis);
395
396                    let thumb_offset = match event_type {
397                        ScrollbarMouseEvent::GutterClick => thumb_size / 2.,
398                        ScrollbarMouseEvent::ThumbDrag(thumb_offset) => thumb_offset,
399                    };
400
401                    let thumb_start = (event_position.along(axis)
402                        - padded_bounds.origin.along(axis)
403                        - thumb_offset)
404                        .clamp(px(0.), viewport_size - thumb_size);
405
406                    let max_offset = max_offset.along(axis);
407                    let percentage = if viewport_size > thumb_size {
408                        thumb_start / (viewport_size - thumb_size)
409                    } else {
410                        0.
411                    };
412
413                    -max_offset * percentage
414                };
415
416            window.on_mouse_event({
417                let state = self.state.clone();
418                move |event: &MouseDownEvent, phase, _, _| {
419                    if !phase.bubble()
420                        || event.button != MouseButton::Left
421                        || !bounds.contains(&event.position)
422                    {
423                        return;
424                    }
425
426                    if thumb_bounds.contains(&event.position) {
427                        let offset = event.position.along(axis) - thumb_bounds.origin.along(axis);
428                        state.set_dragging(offset);
429                    } else {
430                        let scroll_handle = state.scroll_handle();
431                        let click_offset = compute_click_offset(
432                            event.position,
433                            scroll_handle.max_offset(),
434                            ScrollbarMouseEvent::GutterClick,
435                        );
436                        scroll_handle
437                            .set_offset(scroll_handle.offset().apply_along(axis, |_| click_offset));
438                    }
439                }
440            });
441
442            window.on_mouse_event({
443                let state = self.state.clone();
444                let scroll_handle = self.state.scroll_handle().clone();
445                move |event: &ScrollWheelEvent, phase, window, cx| {
446                    if phase.bubble() {
447                        state.unhide(&event.position, cx);
448
449                        if bounds.contains(&event.position) {
450                            let current_offset = scroll_handle.offset();
451                            scroll_handle.set_offset(
452                                current_offset + event.delta.pixel_delta(window.line_height()),
453                            );
454                        }
455                    }
456                }
457            });
458
459            window.on_mouse_event({
460                let state = self.state.clone();
461                move |event: &MouseMoveEvent, phase, window, cx| {
462                    if phase.bubble() {
463                        state.unhide(&event.position, cx);
464
465                        match state.thumb_state.get() {
466                            ThumbState::Dragging(drag_state) if event.dragging() => {
467                                let scroll_handle = state.scroll_handle();
468                                let drag_offset = compute_click_offset(
469                                    event.position,
470                                    scroll_handle.max_offset(),
471                                    ScrollbarMouseEvent::ThumbDrag(drag_state),
472                                );
473                                scroll_handle.set_offset(
474                                    scroll_handle.offset().apply_along(axis, |_| drag_offset),
475                                );
476                                window.refresh();
477                                if let Some(id) = state.parent_id {
478                                    cx.notify(id);
479                                }
480                            }
481                            _ if event.pressed_button.is_none() => {
482                                state.set_thumb_hovered(thumb_bounds.contains(&event.position))
483                            }
484                            _ => {}
485                        }
486                    }
487                }
488            });
489
490            window.on_mouse_event({
491                let state = self.state.clone();
492                move |event: &MouseUpEvent, phase, _, cx| {
493                    if phase.bubble() {
494                        if state.is_dragging() {
495                            state.scroll_handle().drag_ended();
496                            if let Some(id) = state.parent_id {
497                                cx.notify(id);
498                            }
499                        }
500                        state.set_thumb_hovered(thumb_bounds.contains(&event.position));
501                    }
502                }
503            });
504        })
505    }
506}
507
508impl IntoElement for Scrollbar {
509    type Element = Self;
510
511    fn into_element(self) -> Self::Element {
512        self
513    }
514}