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