scrollbar.rs

  1#![allow(missing_docs)]
  2use std::{any::Any, cell::Cell, fmt::Debug, ops::Range, rc::Rc, sync::Arc};
  3
  4use crate::{prelude::*, px, relative, IntoElement};
  5use gpui::{
  6    point, quad, Along, Axis as ScrollbarAxis, Bounds, ContentMask, Corners, Edges, Element,
  7    ElementId, Entity, EntityId, GlobalElementId, Hitbox, Hsla, LayoutId, MouseDownEvent,
  8    MouseMoveEvent, MouseUpEvent, Pixels, Point, ScrollHandle, ScrollWheelEvent, Size, Style,
  9    UniformListScrollHandle, View, WindowContext,
 10};
 11
 12pub struct Scrollbar {
 13    thumb: Range<f32>,
 14    state: ScrollbarState,
 15    kind: ScrollbarAxis,
 16}
 17
 18impl ScrollableHandle for UniformListScrollHandle {
 19    fn content_size(&self) -> Option<ContentSize> {
 20        Some(ContentSize {
 21            size: self.0.borrow().last_item_size.map(|size| size.contents)?,
 22            scroll_adjustment: None,
 23        })
 24    }
 25
 26    fn set_offset(&self, point: Point<Pixels>) {
 27        self.0.borrow().base_handle.set_offset(point);
 28    }
 29
 30    fn offset(&self) -> Point<Pixels> {
 31        self.0.borrow().base_handle.offset()
 32    }
 33
 34    fn viewport(&self) -> Bounds<Pixels> {
 35        self.0.borrow().base_handle.bounds()
 36    }
 37
 38    fn as_any(&self) -> &dyn Any {
 39        self
 40    }
 41}
 42
 43impl ScrollableHandle for ScrollHandle {
 44    fn content_size(&self) -> Option<ContentSize> {
 45        let last_children_index = self.children_count().checked_sub(1)?;
 46
 47        let mut last_item = self.bounds_for_item(last_children_index)?;
 48        let mut scroll_adjustment = None;
 49
 50        if last_children_index != 0 {
 51            // todo: PO: this is slightly wrong for horizontal scrollbar, as the last item is not necessarily the longest one.
 52            let first_item = self.bounds_for_item(0)?;
 53            last_item.size.height += last_item.origin.y;
 54            last_item.size.width += last_item.origin.x;
 55
 56            scroll_adjustment = Some(first_item.origin);
 57            last_item.size.height -= first_item.origin.y;
 58            last_item.size.width -= first_item.origin.x;
 59        }
 60
 61        Some(ContentSize {
 62            size: last_item.size,
 63            scroll_adjustment,
 64        })
 65    }
 66
 67    fn set_offset(&self, point: Point<Pixels>) {
 68        self.set_offset(point);
 69    }
 70
 71    fn offset(&self) -> Point<Pixels> {
 72        self.offset()
 73    }
 74
 75    fn viewport(&self) -> Bounds<Pixels> {
 76        self.bounds()
 77    }
 78
 79    fn as_any(&self) -> &dyn Any {
 80        self
 81    }
 82}
 83
 84#[derive(Debug)]
 85pub struct ContentSize {
 86    pub size: Size<Pixels>,
 87    pub scroll_adjustment: Option<Point<Pixels>>,
 88}
 89
 90pub trait ScrollableHandle: Debug + 'static {
 91    fn content_size(&self) -> Option<ContentSize>;
 92    fn set_offset(&self, point: Point<Pixels>);
 93    fn offset(&self) -> Point<Pixels>;
 94    fn viewport(&self) -> Bounds<Pixels>;
 95    fn as_any(&self) -> &dyn Any;
 96}
 97
 98/// A scrollbar state that should be persisted across frames.
 99#[derive(Clone, Debug)]
100pub struct ScrollbarState {
101    // If Some(), there's an active drag, offset by percentage from the origin of a thumb.
102    drag: Rc<Cell<Option<f32>>>,
103    parent_id: Option<EntityId>,
104    scroll_handle: Arc<dyn ScrollableHandle>,
105}
106
107impl ScrollbarState {
108    pub fn new(scroll: impl ScrollableHandle) -> Self {
109        Self {
110            drag: Default::default(),
111            parent_id: None,
112            scroll_handle: Arc::new(scroll),
113        }
114    }
115
116    /// Set a parent view which should be notified whenever this Scrollbar gets a scroll event.
117    pub fn parent_view<V: 'static>(mut self, v: &View<V>) -> Self {
118        self.parent_id = Some(v.entity_id());
119        self
120    }
121
122    pub fn scroll_handle(&self) -> &Arc<dyn ScrollableHandle> {
123        &self.scroll_handle
124    }
125
126    pub fn is_dragging(&self) -> bool {
127        self.drag.get().is_some()
128    }
129
130    fn thumb_range(&self, axis: ScrollbarAxis) -> Option<Range<f32>> {
131        const MINIMUM_SCROLLBAR_PERCENTAGE_SIZE: f32 = 0.005;
132        let ContentSize {
133            size: main_dimension_size,
134            scroll_adjustment,
135        } = self.scroll_handle.content_size()?;
136        let main_dimension_size = main_dimension_size.along(axis).0;
137        let mut current_offset = self.scroll_handle.offset().along(axis).min(px(0.)).abs().0;
138        if let Some(adjustment) = scroll_adjustment.and_then(|adjustment| {
139            let adjust = adjustment.along(axis).0;
140            if adjust < 0.0 {
141                Some(adjust)
142            } else {
143                None
144            }
145        }) {
146            current_offset -= adjustment;
147        }
148
149        let mut percentage = current_offset / main_dimension_size;
150        let viewport_size = self.scroll_handle.viewport().size;
151        let end_offset = (current_offset + viewport_size.along(axis).0) / main_dimension_size;
152        // Scroll handle might briefly report an offset greater than the length of a list;
153        // in such case we'll adjust the starting offset as well to keep the scrollbar thumb length stable.
154        let overshoot = (end_offset - 1.).clamp(0., 1.);
155        if overshoot > 0. {
156            percentage -= overshoot;
157        }
158        if percentage + MINIMUM_SCROLLBAR_PERCENTAGE_SIZE > 1.0 || end_offset > main_dimension_size
159        {
160            return None;
161        }
162        if main_dimension_size < viewport_size.along(axis).0 {
163            return None;
164        }
165        let end_offset = end_offset.clamp(percentage + MINIMUM_SCROLLBAR_PERCENTAGE_SIZE, 1.);
166        Some(percentage..end_offset)
167    }
168}
169
170impl Scrollbar {
171    pub fn vertical(state: ScrollbarState) -> Option<Self> {
172        Self::new(state, ScrollbarAxis::Vertical)
173    }
174
175    pub fn horizontal(state: ScrollbarState) -> Option<Self> {
176        Self::new(state, ScrollbarAxis::Horizontal)
177    }
178
179    fn new(state: ScrollbarState, kind: ScrollbarAxis) -> Option<Self> {
180        let thumb = state.thumb_range(kind)?;
181        Some(Self { thumb, state, kind })
182    }
183}
184
185impl Element for Scrollbar {
186    type RequestLayoutState = ();
187
188    type PrepaintState = Hitbox;
189
190    fn id(&self) -> Option<ElementId> {
191        None
192    }
193
194    fn request_layout(
195        &mut self,
196        _id: Option<&GlobalElementId>,
197        cx: &mut WindowContext,
198    ) -> (LayoutId, Self::RequestLayoutState) {
199        let mut style = Style::default();
200        style.flex_grow = 1.;
201        style.flex_shrink = 1.;
202
203        if self.kind == ScrollbarAxis::Vertical {
204            style.size.width = px(12.).into();
205            style.size.height = relative(1.).into();
206        } else {
207            style.size.width = relative(1.).into();
208            style.size.height = px(12.).into();
209        }
210
211        (cx.request_layout(style, None), ())
212    }
213
214    fn prepaint(
215        &mut self,
216        _id: Option<&GlobalElementId>,
217        bounds: Bounds<Pixels>,
218        _request_layout: &mut Self::RequestLayoutState,
219        cx: &mut WindowContext,
220    ) -> Self::PrepaintState {
221        cx.with_content_mask(Some(ContentMask { bounds }), |cx| {
222            cx.insert_hitbox(bounds, false)
223        })
224    }
225
226    fn paint(
227        &mut self,
228        _id: Option<&GlobalElementId>,
229        bounds: Bounds<Pixels>,
230        _request_layout: &mut Self::RequestLayoutState,
231        _prepaint: &mut Self::PrepaintState,
232        cx: &mut WindowContext,
233    ) {
234        cx.with_content_mask(Some(ContentMask { bounds }), |cx| {
235            let colors = cx.theme().colors();
236            let thumb_background = colors
237                .surface_background
238                .blend(colors.scrollbar_thumb_background);
239            let is_vertical = self.kind == ScrollbarAxis::Vertical;
240            let extra_padding = px(5.0);
241            let padded_bounds = if is_vertical {
242                Bounds::from_corners(
243                    bounds.origin + point(Pixels::ZERO, extra_padding),
244                    bounds.bottom_right() - point(Pixels::ZERO, extra_padding * 3),
245                )
246            } else {
247                Bounds::from_corners(
248                    bounds.origin + point(extra_padding, Pixels::ZERO),
249                    bounds.bottom_right() - point(extra_padding * 3, Pixels::ZERO),
250                )
251            };
252
253            let mut thumb_bounds = if is_vertical {
254                let thumb_offset = self.thumb.start * padded_bounds.size.height;
255                let thumb_end = self.thumb.end * padded_bounds.size.height;
256                let thumb_upper_left = point(
257                    padded_bounds.origin.x,
258                    padded_bounds.origin.y + thumb_offset,
259                );
260                let thumb_lower_right = point(
261                    padded_bounds.origin.x + padded_bounds.size.width,
262                    padded_bounds.origin.y + thumb_end,
263                );
264                Bounds::from_corners(thumb_upper_left, thumb_lower_right)
265            } else {
266                let thumb_offset = self.thumb.start * padded_bounds.size.width;
267                let thumb_end = self.thumb.end * padded_bounds.size.width;
268                let thumb_upper_left = point(
269                    padded_bounds.origin.x + thumb_offset,
270                    padded_bounds.origin.y,
271                );
272                let thumb_lower_right = point(
273                    padded_bounds.origin.x + thumb_end,
274                    padded_bounds.origin.y + padded_bounds.size.height,
275                );
276                Bounds::from_corners(thumb_upper_left, thumb_lower_right)
277            };
278            let corners = if is_vertical {
279                thumb_bounds.size.width /= 1.5;
280                Corners::all(thumb_bounds.size.width / 2.0)
281            } else {
282                thumb_bounds.size.height /= 1.5;
283                Corners::all(thumb_bounds.size.height / 2.0)
284            };
285            cx.paint_quad(quad(
286                thumb_bounds,
287                corners,
288                thumb_background,
289                Edges::default(),
290                Hsla::transparent_black(),
291            ));
292
293            let scroll = self.state.scroll_handle.clone();
294            let kind = self.kind;
295            let thumb_percentage_size = self.thumb.end - self.thumb.start;
296
297            cx.on_mouse_event({
298                let scroll = scroll.clone();
299                let state = self.state.clone();
300                let axis = self.kind;
301                move |event: &MouseDownEvent, phase, _cx| {
302                    if !(phase.bubble() && bounds.contains(&event.position)) {
303                        return;
304                    }
305
306                    if thumb_bounds.contains(&event.position) {
307                        let thumb_offset = (event.position.along(axis)
308                            - thumb_bounds.origin.along(axis))
309                            / bounds.size.along(axis);
310                        state.drag.set(Some(thumb_offset));
311                    } else if let Some(ContentSize {
312                        size: item_size, ..
313                    }) = scroll.content_size()
314                    {
315                        match kind {
316                            ScrollbarAxis::Horizontal => {
317                                let percentage =
318                                    (event.position.x - bounds.origin.x) / bounds.size.width;
319                                let max_offset = item_size.width;
320                                let percentage = percentage.min(1. - thumb_percentage_size);
321                                scroll
322                                    .set_offset(point(-max_offset * percentage, scroll.offset().y));
323                            }
324                            ScrollbarAxis::Vertical => {
325                                let percentage =
326                                    (event.position.y - bounds.origin.y) / bounds.size.height;
327                                let max_offset = item_size.height;
328                                let percentage = percentage.min(1. - thumb_percentage_size);
329                                scroll
330                                    .set_offset(point(scroll.offset().x, -max_offset * percentage));
331                            }
332                        }
333                    }
334                }
335            });
336            cx.on_mouse_event({
337                let scroll = scroll.clone();
338                move |event: &ScrollWheelEvent, phase, cx| {
339                    if phase.bubble() && bounds.contains(&event.position) {
340                        let current_offset = scroll.offset();
341                        scroll
342                            .set_offset(current_offset + event.delta.pixel_delta(cx.line_height()));
343                    }
344                }
345            });
346            let state = self.state.clone();
347            let kind = self.kind;
348            cx.on_mouse_event(move |event: &MouseMoveEvent, _, cx| {
349                if let Some(drag_state) = state.drag.get().filter(|_| event.dragging()) {
350                    if let Some(ContentSize {
351                        size: item_size, ..
352                    }) = scroll.content_size()
353                    {
354                        match kind {
355                            ScrollbarAxis::Horizontal => {
356                                let max_offset = item_size.width;
357                                let percentage = (event.position.x - bounds.origin.x)
358                                    / bounds.size.width
359                                    - drag_state;
360
361                                let percentage = percentage.min(1. - thumb_percentage_size);
362                                scroll
363                                    .set_offset(point(-max_offset * percentage, scroll.offset().y));
364                            }
365                            ScrollbarAxis::Vertical => {
366                                let max_offset = item_size.height;
367                                let percentage = (event.position.y - bounds.origin.y)
368                                    / bounds.size.height
369                                    - drag_state;
370
371                                let percentage = percentage.min(1. - thumb_percentage_size);
372                                scroll
373                                    .set_offset(point(scroll.offset().x, -max_offset * percentage));
374                            }
375                        };
376
377                        if let Some(id) = state.parent_id {
378                            cx.notify(Some(id));
379                        }
380                    }
381                } else {
382                    state.drag.set(None);
383                }
384            });
385            let state = self.state.clone();
386            cx.on_mouse_event(move |_event: &MouseUpEvent, phase, cx| {
387                if phase.bubble() {
388                    state.drag.take();
389                    if let Some(id) = state.parent_id {
390                        cx.notify(Some(id));
391                    }
392                }
393            });
394        })
395    }
396}
397
398impl IntoElement for Scrollbar {
399    type Element = Self;
400
401    fn into_element(self) -> Self::Element {
402        self
403    }
404}