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