1use std::time::{Duration, Instant};
18
19use gpui::{
20 canvas, div, prelude::*, px, App, Bounds, Div, ElementId, Hsla, IntoElement, MouseButton,
21 MouseDownEvent, MouseMoveEvent, Pixels, Point, RenderOnce, ScrollHandle, Window,
22};
23use herogpui_core::{element_id, Orientation};
24use herogpui_theme::ActiveTheme;
25
26const THUMB_MIN: f32 = 24.0;
27const TRACK: f32 = 8.0;
28const IDLE: Duration = Duration::from_millis(800);
29
30#[derive(Clone, Copy)]
31struct Drag {
32 pointer: f32,
33 offset: f32,
34}
35
36#[derive(Clone)]
37struct BarState {
38 drag: Option<Drag>,
39 hover: bool,
40 last_offset: f32,
41 last_moved: Option<Instant>,
42}
43
44impl Default for BarState {
45 fn default() -> Self {
46 Self {
47 drag: None,
48 hover: false,
49 last_offset: 0.0,
50 last_moved: None,
51 }
52 }
53}
54
55#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
57#[derive(IntoElement)]
58pub struct Scrollbar {
59 id: ElementId,
60 handle: ScrollHandle,
61 orientation: Orientation,
62 track: Option<Pixels>,
64 inset: Option<Pixels>,
66 radius: Option<Pixels>,
68 thumb_color: Option<Hsla>,
70 thumb_hover_color: Option<Hsla>,
74 auto_hide: bool,
76 sx: Option<Box<gpui::StyleRefinement>>,
78}
79
80impl Scrollbar {
81 pub fn new(id: impl Into<ElementId>, handle: ScrollHandle) -> Self {
83 Self {
84 id: id.into(),
85 handle,
86 orientation: Orientation::Vertical,
87 track: None,
88 inset: None,
89 radius: None,
90 thumb_color: None,
91 thumb_hover_color: None,
92 auto_hide: true,
93 sx: None,
94 }
95 }
96
97 pub fn orientation(mut self, orientation: Orientation) -> Self {
99 self.orientation = orientation;
100 self
101 }
102
103 pub fn track(mut self, thickness: impl Into<Pixels>) -> Self {
110 self.track = Some(thickness.into());
111 self
112 }
113
114 pub fn inset(mut self, inset: impl Into<Pixels>) -> Self {
119 self.inset = Some(inset.into());
120 self
121 }
122
123 pub fn radius(mut self, radius: impl Into<Pixels>) -> Self {
129 self.radius = Some(radius.into());
130 self
131 }
132
133 pub fn thumb_color(mut self, color: impl Into<Hsla>) -> Self {
137 self.thumb_color = Some(color.into());
138 self
139 }
140
141 pub fn thumb_hover_color(mut self, color: impl Into<Hsla>) -> Self {
150 self.thumb_hover_color = Some(color.into());
151 self
152 }
153
154 pub fn auto_hide(mut self, v: bool) -> Self {
160 self.auto_hide = v;
161 self
162 }
163
164 pub fn sx(mut self, style: impl FnOnce(Div) -> Div) -> Self {
174 crate::util::refine_sx(&mut self.sx, style);
175 self
176 }
177}
178
179impl RenderOnce for Scrollbar {
180 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
181 let handle = self.handle;
182 let horizontal = self.orientation.is_horizontal();
183 let max = if horizontal {
184 f32::from(handle.max_offset().x)
185 } else {
186 f32::from(handle.max_offset().y)
187 };
188 if max < 1.0 {
189 return div().into_any_element();
190 }
191
192 let offset = if horizontal {
193 f32::from(handle.offset().x)
194 } else {
195 f32::from(handle.offset().y)
196 };
197 let state = window.use_keyed_state(element_id::scoped(&self.id, "bar"), cx, |_, _| {
198 BarState::default()
199 });
200 let snapshot = state.read(cx).clone();
201 if (snapshot.last_offset - offset).abs() > 0.5 {
202 state.update(cx, |s, _| {
203 s.last_offset = offset;
204 s.last_moved = Some(Instant::now());
205 });
206 }
207
208 let platform_auto_hide = cx.should_auto_hide_scrollbars();
209 let live = state.read(cx);
210 let recently_moved = live.last_moved.is_some_and(|at| at.elapsed() < IDLE);
211 let show_thumb = thumb_visible(
212 self.auto_hide,
213 platform_auto_hide,
214 live.hover,
215 live.drag.is_some(),
216 recently_moved,
217 );
218 let hovered = live.hover;
219 let token = cx.colors().scrollbar;
220
221 let sx_background = crate::util::sx_background(&self.sx);
227 let resting = sx_background.or(self.thumb_color).unwrap_or(token);
228 let (idle_fill, hover_fill) = crate::util::fade_endpoints(
229 Some((resting, resting)),
230 sx_background,
231 self.thumb_hover_color,
232 )
233 .unwrap_or((resting, resting));
234 let thumb_fill = if hovered { hover_fill } else { idle_fill };
235
236 let sx_size = crate::util::sx_pixel_size(&self.sx);
241 let thickness = if horizontal {
242 sx_size.height.or(self.track).unwrap_or_else(|| px(TRACK))
243 } else {
244 sx_size.width.or(self.track).unwrap_or_else(|| px(TRACK))
245 };
246 let default_radius = px(f32::from(thickness) / 2.0);
247 let corners = crate::util::fill_unspecified_corners(
248 crate::util::sx_radius(&self.sx),
249 self.radius.or(Some(default_radius)),
250 );
251 let corner_radii = gpui::Corners {
252 top_left: corners.top_left.unwrap_or(default_radius),
253 top_right: corners.top_right.unwrap_or(default_radius),
254 bottom_right: corners.bottom_right.unwrap_or(default_radius),
255 bottom_left: corners.bottom_left.unwrap_or(default_radius),
256 };
257 let inset = self.inset.unwrap_or_else(|| px(0.));
258 let axis = self.orientation;
259 let handle_move = handle.clone();
260 let drag_state = state.clone();
261 let hover_state = state.clone();
262 let leave_state = state.clone();
263
264 let mut track = div()
265 .id(self.id)
266 .absolute()
267 .overflow_hidden()
268 .when(horizontal, |el| {
269 el.left_0().right_0().bottom_0().h(thickness)
270 })
271 .when(!horizontal, |el| {
272 el.top_0().bottom_0().right_0().w(thickness)
273 })
274 .on_mouse_down(MouseButton::Left, {
275 let handle = handle.clone();
276 let state = state.clone();
277 move |ev: &MouseDownEvent, window, cx| {
278 jump_or_grab(&handle, axis, ev.position, &state, window, cx);
279 }
280 })
281 .on_mouse_move({
282 let handle = handle_move;
283 let state = drag_state;
284 move |ev: &MouseMoveEvent, window, cx| {
285 drag_to(&handle, axis, ev.position, &state, window, cx);
286 }
287 })
288 .on_mouse_up(MouseButton::Left, {
289 let state = state.clone();
290 move |_, _, cx| {
291 state.update(cx, |s, cx| {
292 s.drag = None;
293 cx.notify();
294 });
295 }
296 })
297 .on_hover(move |inside, _, cx| {
298 if *inside {
299 hover_state.update(cx, |s, cx| {
300 s.hover = true;
301 cx.notify();
302 });
303 } else {
304 leave_state.update(cx, |s, cx| {
305 s.hover = false;
306 cx.notify();
307 });
308 }
309 });
310
311 if show_thumb {
312 let paint_handle = handle;
313 track = track.child(
314 canvas(
315 move |bounds, _, _| bounds,
316 move |bounds, _, window, _| {
317 if let Some(thumb_bounds) = thumb_bounds(&paint_handle, axis, bounds) {
318 let painted = inset_bounds(thumb_bounds, inset);
319 window.paint_quad(
320 gpui::fill(painted, thumb_fill).corner_radii(corner_radii),
321 );
322 }
323 },
324 )
325 .size_full(),
326 );
327 }
328
329 crate::util::apply_sx(track, &self.sx).into_any_element()
330 }
331}
332
333fn thumb_visible(
340 auto_hide: bool,
341 platform_auto_hide: bool,
342 hover: bool,
343 dragging: bool,
344 recently_moved: bool,
345) -> bool {
346 !auto_hide || !platform_auto_hide || hover || dragging || recently_moved
347}
348
349fn inset_bounds(bounds: Bounds<Pixels>, inset: Pixels) -> Bounds<Pixels> {
352 let inset = f32::from(inset);
353 Bounds {
354 origin: Point {
355 x: bounds.origin.x + px(inset),
356 y: bounds.origin.y + px(inset),
357 },
358 size: gpui::Size {
359 width: px((f32::from(bounds.size.width) - 2.0 * inset).max(0.0)),
360 height: px((f32::from(bounds.size.height) - 2.0 * inset).max(0.0)),
361 },
362 }
363}
364
365fn axis_size(bounds: Bounds<Pixels>, horizontal: bool) -> f32 {
366 if horizontal {
367 f32::from(bounds.size.width)
368 } else {
369 f32::from(bounds.size.height)
370 }
371}
372
373fn axis_origin(bounds: Bounds<Pixels>, horizontal: bool) -> f32 {
374 if horizontal {
375 f32::from(bounds.origin.x)
376 } else {
377 f32::from(bounds.origin.y)
378 }
379}
380
381fn thumb_metrics(
382 handle: &ScrollHandle,
383 axis: Orientation,
384 track: Bounds<Pixels>,
385) -> Option<(f32, f32, f32)> {
386 let horizontal = axis.is_horizontal();
387 let max = if horizontal {
388 f32::from(handle.max_offset().x)
389 } else {
390 f32::from(handle.max_offset().y)
391 };
392 if max < 1.0 {
393 return None;
394 }
395 let offset = if horizontal {
396 f32::from(handle.offset().x)
397 } else {
398 f32::from(handle.offset().y)
399 };
400 let track_len = axis_size(track, horizontal);
401 if track_len < 1.0 {
402 return None;
403 }
404 let content = track_len + max;
405 let thumb_len = (track_len * (track_len / content))
406 .max(THUMB_MIN)
407 .min(track_len);
408 let travel = (track_len - thumb_len).max(0.0);
409 let t = (-offset / max).clamp(0.0, 1.0);
410 Some((thumb_len, travel * t, max))
411}
412
413fn thumb_bounds(
414 handle: &ScrollHandle,
415 axis: Orientation,
416 track: Bounds<Pixels>,
417) -> Option<Bounds<Pixels>> {
418 let (thumb_len, thumb_start, _) = thumb_metrics(handle, axis, track)?;
419 let horizontal = axis.is_horizontal();
420 Some(if horizontal {
421 Bounds {
422 origin: Point {
423 x: track.origin.x + px(thumb_start),
424 y: track.origin.y,
425 },
426 size: gpui::Size {
427 width: px(thumb_len),
428 height: track.size.height,
429 },
430 }
431 } else {
432 Bounds {
433 origin: Point {
434 x: track.origin.x,
435 y: track.origin.y + px(thumb_start),
436 },
437 size: gpui::Size {
438 width: track.size.width,
439 height: px(thumb_len),
440 },
441 }
442 })
443}
444
445fn jump_or_grab(
446 handle: &ScrollHandle,
447 axis: Orientation,
448 pointer: Point<Pixels>,
449 state: &gpui::Entity<BarState>,
450 window: &mut Window,
451 cx: &mut App,
452) {
453 let track = handle.bounds();
454 let Some((thumb_len, thumb_start, max)) = thumb_metrics(handle, axis, track) else {
455 return;
456 };
457 let horizontal = axis.is_horizontal();
458 let pointer_v = if horizontal {
459 f32::from(pointer.x)
460 } else {
461 f32::from(pointer.y)
462 };
463 let origin = axis_origin(track, horizontal);
464 let rel = pointer_v - origin;
465 let offset = if horizontal {
466 f32::from(handle.offset().x)
467 } else {
468 f32::from(handle.offset().y)
469 };
470 if rel >= thumb_start && rel <= thumb_start + thumb_len {
471 state.update(cx, |s, cx| {
472 s.drag = Some(Drag {
473 pointer: pointer_v,
474 offset,
475 });
476 cx.notify();
477 });
478 return;
479 }
480 let travel = (axis_size(track, horizontal) - thumb_len).max(1.0);
481 let t = ((rel - thumb_len / 2.0) / travel).clamp(0.0, 1.0);
482 set_axis_offset(handle, axis, -t * max);
483 state.update(cx, |s, cx| {
484 s.last_offset = -t * max;
485 s.last_moved = Some(Instant::now());
486 cx.notify();
487 });
488 let _ = window;
489}
490
491fn drag_to(
492 handle: &ScrollHandle,
493 axis: Orientation,
494 pointer: Point<Pixels>,
495 state: &gpui::Entity<BarState>,
496 _window: &mut Window,
497 cx: &mut App,
498) {
499 let Some(drag) = state.read(cx).drag else {
500 return;
501 };
502 let track = handle.bounds();
503 let Some((thumb_len, _, max)) = thumb_metrics(handle, axis, track) else {
504 return;
505 };
506 let horizontal = axis.is_horizontal();
507 let pointer_v = if horizontal {
508 f32::from(pointer.x)
509 } else {
510 f32::from(pointer.y)
511 };
512 let travel = (axis_size(track, horizontal) - thumb_len).max(1.0);
513 let delta = (pointer_v - drag.pointer) / travel * max;
514 let next = (drag.offset - delta).clamp(-max, 0.0);
515 set_axis_offset(handle, axis, next);
516 state.update(cx, |s, cx| {
517 s.last_offset = next;
518 s.last_moved = Some(Instant::now());
519 cx.notify();
520 });
521}
522
523fn set_axis_offset(handle: &ScrollHandle, axis: Orientation, value: f32) {
524 let mut point = handle.offset();
525 if axis.is_horizontal() {
526 point.x = px(value);
527 } else {
528 point.y = px(value);
529 }
530 handle.set_offset(point);
531}
532
533#[cfg(test)]
534mod tests {
535 use super::*;
536
537 #[test]
542 fn the_visibility_decision_follows_the_platform_and_the_caller() {
543 assert!(!thumb_visible(true, true, false, false, false));
547 assert!(thumb_visible(true, true, true, false, false));
548 assert!(thumb_visible(true, true, false, true, false));
549 assert!(thumb_visible(true, true, false, false, true));
550 assert!(thumb_visible(false, true, false, false, false));
552 assert!(thumb_visible(true, false, false, false, false));
554 assert!(thumb_visible(false, false, false, false, false));
555 }
556
557 #[test]
558 fn inset_shrinks_every_edge_without_going_negative() {
559 let bounds = Bounds {
560 origin: Point {
561 x: px(10.0),
562 y: px(20.0),
563 },
564 size: gpui::Size {
565 width: px(8.0),
566 height: px(40.0),
567 },
568 };
569 let untouched = inset_bounds(bounds, px(0.0));
570 assert_eq!(untouched.origin.x, px(10.0));
571 assert_eq!(untouched.size.width, px(8.0));
572 assert_eq!(untouched.size.height, px(40.0));
573
574 let shrunk = inset_bounds(bounds, px(2.0));
575 assert_eq!(shrunk.origin.x, px(12.0));
576 assert_eq!(shrunk.origin.y, px(22.0));
577 assert_eq!(shrunk.size.width, px(4.0));
578 assert_eq!(shrunk.size.height, px(36.0));
579
580 let collapsed = inset_bounds(bounds, px(30.0));
581 assert_eq!(collapsed.size.width, px(0.0));
582 assert_eq!(collapsed.size.height, px(0.0));
583 }
584}
585
586crate::util::impl_component_styled!(Scrollbar);