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}