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}