1use gpui::prelude::*;
17use gpui::{
18 div, point, px, Axis, Bounds, ElementId, Entity, IntoElement, ListState, MouseButton,
19 MouseDownEvent, Pixels, Render, ScrollHandle, Window,
20};
21
22use crate::devtools::Probed;
23use crate::theme::{theme, Size};
24
25const MIN_THUMB: f32 = 24.0;
27
28pub(crate) fn thumb(viewport: f32, max: f32, offset: f32, track: f32) -> Option<(f32, f32)> {
32 if max <= 0.5 || viewport <= 0.0 || track <= 0.0 {
33 return None;
34 }
35 let len = (track * viewport / (viewport + max))
36 .max(MIN_THUMB)
37 .min(track);
38 let travel = track - len;
39 let start = travel * (offset / max).clamp(0.0, 1.0);
40 Some((start, len))
41}
42
43pub(crate) fn offset_for(pos: f32, max: f32, track: f32, len: f32) -> f32 {
46 let travel = track - len;
47 if travel <= 0.0 {
48 return 0.0;
49 }
50 (pos / travel).clamp(0.0, 1.0) * max
51}
52
53struct ScrollDrag(ElementId);
57
58struct Ghost;
60
61impl Render for Ghost {
62 fn render(&mut self, _window: &mut Window, _cx: &mut gpui::Context<Self>) -> impl IntoElement {
63 div()
64 }
65}
66
67#[derive(Clone)]
70enum Source {
71 Handle(ScrollHandle),
72 List(ListState),
73}
74
75impl Source {
76 fn bounds(&self) -> Bounds<Pixels> {
77 match self {
78 Source::Handle(h) => h.bounds(),
79 Source::List(l) => l.viewport_bounds(),
80 }
81 }
82
83 fn max_offset(&self) -> gpui::Size<Pixels> {
84 match self {
85 Source::Handle(h) => h.max_offset(),
86 Source::List(l) => l.max_offset_for_scrollbar(),
87 }
88 }
89
90 fn offset(&self) -> gpui::Point<Pixels> {
91 match self {
92 Source::Handle(h) => h.offset(),
93 Source::List(l) => l.scroll_px_offset_for_scrollbar(),
94 }
95 }
96
97 fn set_offset(&self, to: gpui::Point<Pixels>) {
98 match self {
99 Source::Handle(h) => h.set_offset(to),
100 Source::List(l) => l.set_offset_from_scrollbar(to),
101 }
102 }
103
104 fn drag(&self, started: bool) {
105 if let Source::List(l) = self {
106 if started {
107 l.scrollbar_drag_started();
108 } else {
109 l.scrollbar_drag_ended();
110 }
111 }
112 }
113}
114
115#[derive(IntoElement)]
118pub struct Scrollbar {
119 id: ElementId,
120 source: Source,
121 axis: Axis,
122}
123
124impl Scrollbar {
125 pub fn new(id: impl Into<ElementId>, handle: &ScrollHandle) -> Self {
126 Scrollbar {
127 id: id.into(),
128 source: Source::Handle(handle.clone()),
129 axis: Axis::Vertical,
130 }
131 }
132
133 pub fn for_list(id: impl Into<ElementId>, state: &ListState) -> Self {
136 Scrollbar {
137 id: id.into(),
138 source: Source::List(state.clone()),
139 axis: Axis::Vertical,
140 }
141 }
142
143 pub fn horizontal(mut self, horizontal: bool) -> Self {
145 self.axis = if horizontal {
146 Axis::Horizontal
147 } else {
148 Axis::Vertical
149 };
150 self
151 }
152}
153
154fn measure(handle: &Source, axis: Axis) -> (f32, f32, f32, f32) {
156 let bounds: Bounds<Pixels> = handle.bounds();
157 let max = handle.max_offset();
158 let offset = handle.offset();
159 match axis {
160 Axis::Vertical => (
161 bounds.size.height.into(),
162 max.height.into(),
163 -f32::from(offset.y),
164 bounds.origin.y.into(),
165 ),
166 Axis::Horizontal => (
167 bounds.size.width.into(),
168 max.width.into(),
169 -f32::from(offset.x),
170 bounds.origin.x.into(),
171 ),
172 }
173}
174
175impl RenderOnce for Scrollbar {
176 fn render(self, window: &mut Window, cx: &mut gpui::App) -> impl IntoElement {
177 let axis = self.axis;
178 let vertical = axis == Axis::Vertical;
179 let (viewport, max, offset, origin) = measure(&self.source, axis);
180 if viewport <= 0.0 {
183 window.request_animation_frame();
184 }
185 let Some((start, len)) = thumb(viewport, max, offset, viewport) else {
186 return div()
187 .probe("Scrollbar")
188 .attr("visible", "no")
189 .into_any_element();
190 };
191
192 let t = theme(cx);
193 let girth = t.spacing(Size::Sm);
194 let inset = t.spacing(Size::Xs) / 4.0;
195 let radius = t.radius(Size::Xl);
196 let rest = t.dimmed().hsla().opacity(0.45);
197 let active = t.dimmed().hsla().opacity(0.8);
198
199 let grab: Entity<f32> = window.use_keyed_state((self.id.clone(), "grab"), cx, |_, _| 0.0);
202
203 let pos = move |p: gpui::Point<Pixels>| -> f32 {
204 if vertical {
205 f32::from(p.y) - origin
206 } else {
207 f32::from(p.x) - origin
208 }
209 };
210
211 let handle = self.source.clone();
212 let on_track = {
213 let handle = handle.clone();
214 move |ev: &MouseDownEvent, window: &mut Window, _cx: &mut gpui::App| {
215 let to = offset_for(pos(ev.position) - len / 2.0, max, viewport, len);
217 set(&handle, axis, to);
218 window.refresh();
219 }
220 };
221 let on_thumb = {
222 let grab = grab.clone();
223 let source = handle.clone();
224 move |ev: &MouseDownEvent, _window: &mut Window, cx: &mut gpui::App| {
225 let inside = pos(ev.position) - start;
226 source.drag(true);
227 grab.update(cx, |g, _| *g = inside);
228 cx.stop_propagation();
229 }
230 };
231 let on_move = {
232 let handle = handle.clone();
233 let grab = grab.clone();
234 let id = self.id.clone();
235 move |ev: &gpui::DragMoveEvent<ScrollDrag>, window: &mut Window, cx: &mut gpui::App| {
236 if ev.drag(cx).0 != id {
237 return;
238 }
239 let held = *grab.read(cx);
240 let to = offset_for(pos(ev.event.position) - held, max, viewport, len);
241 set(&handle, axis, to);
242 window.refresh();
243 }
244 };
245
246 let mut thumb = div()
247 .id((self.id.clone(), "thumb"))
248 .absolute()
249 .rounded(px(radius))
250 .bg(rest)
251 .hover(move |s| s.bg(active))
252 .on_mouse_down(MouseButton::Left, on_thumb)
253 .on_mouse_up(MouseButton::Left, {
254 let source = handle.clone();
255 move |_, _, _| source.drag(false)
256 })
257 .on_mouse_up_out(MouseButton::Left, {
258 let source = handle.clone();
259 move |_, _, _| source.drag(false)
260 })
261 .on_drag(ScrollDrag(self.id.clone()), |_, _, _, cx| cx.new(|_| Ghost));
262 thumb = if vertical {
263 thumb
264 .left(px(inset))
265 .right(px(inset))
266 .top(px(start))
267 .h(px(len))
268 } else {
269 thumb
270 .top(px(inset))
271 .bottom(px(inset))
272 .left(px(start))
273 .w(px(len))
274 };
275
276 let mut track = div()
277 .id(self.id)
278 .absolute()
279 .on_mouse_down(MouseButton::Left, on_track)
280 .on_drag_move::<ScrollDrag>(on_move)
281 .child(thumb);
282 track = if vertical {
283 track.top_0().bottom_0().right_0().w(px(girth))
284 } else {
285 track.left_0().right_0().bottom_0().h(px(girth))
286 };
287 track
288 .probe("Scrollbar")
289 .attr("visible", "yes")
290 .into_any_element()
291 }
292}
293
294fn set(handle: &Source, axis: Axis, offset: f32) {
296 let now = handle.offset();
297 handle.set_offset(match axis {
298 Axis::Vertical => point(now.x, px(-offset)),
299 Axis::Horizontal => point(px(-offset), now.y),
300 });
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306
307 #[test]
308 fn nothing_to_draw_when_the_content_fits() {
309 assert_eq!(thumb(400.0, 0.0, 0.0, 400.0), None);
310 assert_eq!(thumb(0.0, 100.0, 0.0, 0.0), None);
311 }
312
313 #[test]
314 fn thumb_is_the_visible_fraction_of_the_track() {
315 let (start, len) = thumb(400.0, 1200.0, 0.0, 400.0).unwrap();
317 assert_eq!(start, 0.0);
318 assert_eq!(len, 100.0);
319 }
320
321 #[test]
322 fn thumb_reaches_the_far_end_at_full_scroll() {
323 let (start, len) = thumb(400.0, 1200.0, 1200.0, 400.0).unwrap();
324 assert_eq!(start + len, 400.0);
325 }
326
327 #[test]
328 fn thumb_never_shrinks_below_a_grabbable_size() {
329 let (_, len) = thumb(400.0, 400_000.0, 0.0, 400.0).unwrap();
330 assert_eq!(len, MIN_THUMB);
331 }
332
333 #[test]
334 fn overscroll_is_clamped_onto_the_track() {
335 let (start, len) = thumb(400.0, 1200.0, 5000.0, 400.0).unwrap();
336 assert_eq!(start + len, 400.0);
337 let (start, _) = thumb(400.0, 1200.0, -50.0, 400.0).unwrap();
338 assert_eq!(start, 0.0);
339 }
340
341 #[test]
342 fn dragging_inverts_the_geometry() {
343 let (start, len) = thumb(400.0, 1200.0, 600.0, 400.0).unwrap();
344 let back = offset_for(start, 1200.0, 400.0, len);
345 assert!((back - 600.0).abs() < 0.01, "got {back}");
346 assert_eq!(offset_for(-30.0, 1200.0, 400.0, len), 0.0);
347 assert_eq!(offset_for(9000.0, 1200.0, 400.0, len), 1200.0);
348 }
349
350 #[test]
351 fn a_track_with_no_travel_stays_at_the_top() {
352 assert_eq!(offset_for(10.0, 100.0, 24.0, 24.0), 0.0);
353 }
354}