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, App, 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, Window,
 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 model which should be notified whenever this Scrollbar gets a scroll event.
117    pub fn parent_entity<V: 'static>(mut self, v: &Entity<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        window: &mut Window,
198        cx: &mut App,
199    ) -> (LayoutId, Self::RequestLayoutState) {
200        let mut style = Style::default();
201        style.flex_grow = 1.;
202        style.flex_shrink = 1.;
203
204        if self.kind == ScrollbarAxis::Vertical {
205            style.size.width = px(12.).into();
206            style.size.height = relative(1.).into();
207        } else {
208            style.size.width = relative(1.).into();
209            style.size.height = px(12.).into();
210        }
211
212        (window.request_layout(style, None, cx), ())
213    }
214
215    fn prepaint(
216        &mut self,
217        _id: Option<&GlobalElementId>,
218        bounds: Bounds<Pixels>,
219        _request_layout: &mut Self::RequestLayoutState,
220        window: &mut Window,
221        _: &mut App,
222    ) -> Self::PrepaintState {
223        window.with_content_mask(Some(ContentMask { bounds }), |window| {
224            window.insert_hitbox(bounds, false)
225        })
226    }
227
228    fn paint(
229        &mut self,
230        _id: Option<&GlobalElementId>,
231        bounds: Bounds<Pixels>,
232        _request_layout: &mut Self::RequestLayoutState,
233        _prepaint: &mut Self::PrepaintState,
234        window: &mut Window,
235        cx: &mut App,
236    ) {
237        window.with_content_mask(Some(ContentMask { bounds }), |window| {
238            let colors = cx.theme().colors();
239            let thumb_background = colors
240                .surface_background
241                .blend(colors.scrollbar_thumb_background);
242            let is_vertical = self.kind == ScrollbarAxis::Vertical;
243            let extra_padding = px(5.0);
244            let padded_bounds = if is_vertical {
245                Bounds::from_corners(
246                    bounds.origin + point(Pixels::ZERO, extra_padding),
247                    bounds.bottom_right() - point(Pixels::ZERO, extra_padding * 3),
248                )
249            } else {
250                Bounds::from_corners(
251                    bounds.origin + point(extra_padding, Pixels::ZERO),
252                    bounds.bottom_right() - point(extra_padding * 3, Pixels::ZERO),
253                )
254            };
255
256            let mut thumb_bounds = if is_vertical {
257                let thumb_offset = self.thumb.start * padded_bounds.size.height;
258                let thumb_end = self.thumb.end * padded_bounds.size.height;
259                let thumb_upper_left = point(
260                    padded_bounds.origin.x,
261                    padded_bounds.origin.y + thumb_offset,
262                );
263                let thumb_lower_right = point(
264                    padded_bounds.origin.x + padded_bounds.size.width,
265                    padded_bounds.origin.y + thumb_end,
266                );
267                Bounds::from_corners(thumb_upper_left, thumb_lower_right)
268            } else {
269                let thumb_offset = self.thumb.start * padded_bounds.size.width;
270                let thumb_end = self.thumb.end * padded_bounds.size.width;
271                let thumb_upper_left = point(
272                    padded_bounds.origin.x + thumb_offset,
273                    padded_bounds.origin.y,
274                );
275                let thumb_lower_right = point(
276                    padded_bounds.origin.x + thumb_end,
277                    padded_bounds.origin.y + padded_bounds.size.height,
278                );
279                Bounds::from_corners(thumb_upper_left, thumb_lower_right)
280            };
281            let corners = if is_vertical {
282                thumb_bounds.size.width /= 1.5;
283                Corners::all(thumb_bounds.size.width / 2.0)
284            } else {
285                thumb_bounds.size.height /= 1.5;
286                Corners::all(thumb_bounds.size.height / 2.0)
287            };
288            window.paint_quad(quad(
289                thumb_bounds,
290                corners,
291                thumb_background,
292                Edges::default(),
293                Hsla::transparent_black(),
294            ));
295
296            let scroll = self.state.scroll_handle.clone();
297            let kind = self.kind;
298            let thumb_percentage_size = self.thumb.end - self.thumb.start;
299
300            window.on_mouse_event({
301                let scroll = scroll.clone();
302                let state = self.state.clone();
303                let axis = self.kind;
304                move |event: &MouseDownEvent, phase, _, _| {
305                    if !(phase.bubble() && bounds.contains(&event.position)) {
306                        return;
307                    }
308
309                    if thumb_bounds.contains(&event.position) {
310                        let thumb_offset = (event.position.along(axis)
311                            - thumb_bounds.origin.along(axis))
312                            / bounds.size.along(axis);
313                        state.drag.set(Some(thumb_offset));
314                    } else if let Some(ContentSize {
315                        size: item_size, ..
316                    }) = scroll.content_size()
317                    {
318                        match kind {
319                            ScrollbarAxis::Horizontal => {
320                                let percentage =
321                                    (event.position.x - bounds.origin.x) / bounds.size.width;
322                                let max_offset = item_size.width;
323                                let percentage = percentage.min(1. - thumb_percentage_size);
324                                scroll
325                                    .set_offset(point(-max_offset * percentage, scroll.offset().y));
326                            }
327                            ScrollbarAxis::Vertical => {
328                                let percentage =
329                                    (event.position.y - bounds.origin.y) / bounds.size.height;
330                                let max_offset = item_size.height;
331                                let percentage = percentage.min(1. - thumb_percentage_size);
332                                scroll
333                                    .set_offset(point(scroll.offset().x, -max_offset * percentage));
334                            }
335                        }
336                    }
337                }
338            });
339            window.on_mouse_event({
340                let scroll = scroll.clone();
341                move |event: &ScrollWheelEvent, phase, window, _| {
342                    if phase.bubble() && bounds.contains(&event.position) {
343                        let current_offset = scroll.offset();
344                        scroll.set_offset(
345                            current_offset + event.delta.pixel_delta(window.line_height()),
346                        );
347                    }
348                }
349            });
350            let state = self.state.clone();
351            let kind = self.kind;
352            window.on_mouse_event(move |event: &MouseMoveEvent, _, _, cx| {
353                if let Some(drag_state) = state.drag.get().filter(|_| event.dragging()) {
354                    if let Some(ContentSize {
355                        size: item_size, ..
356                    }) = scroll.content_size()
357                    {
358                        match kind {
359                            ScrollbarAxis::Horizontal => {
360                                let max_offset = item_size.width;
361                                let percentage = (event.position.x - bounds.origin.x)
362                                    / bounds.size.width
363                                    - drag_state;
364
365                                let percentage = percentage.min(1. - thumb_percentage_size);
366                                scroll
367                                    .set_offset(point(-max_offset * percentage, scroll.offset().y));
368                            }
369                            ScrollbarAxis::Vertical => {
370                                let max_offset = item_size.height;
371                                let percentage = (event.position.y - bounds.origin.y)
372                                    / bounds.size.height
373                                    - drag_state;
374
375                                let percentage = percentage.min(1. - thumb_percentage_size);
376                                scroll
377                                    .set_offset(point(scroll.offset().x, -max_offset * percentage));
378                            }
379                        };
380
381                        if let Some(id) = state.parent_id {
382                            cx.notify(id);
383                        }
384                    }
385                } else {
386                    state.drag.set(None);
387                }
388            });
389            let state = self.state.clone();
390            window.on_mouse_event(move |_event: &MouseUpEvent, phase, _, cx| {
391                if phase.bubble() {
392                    state.drag.take();
393                    if let Some(id) = state.parent_id {
394                        cx.notify(id);
395                    }
396                }
397            });
398        })
399    }
400}
401
402impl IntoElement for Scrollbar {
403    type Element = Self;
404
405    fn into_element(self) -> Self::Element {
406        self
407    }
408}