1use gpui::{
2 AnyElement, App, Bounds, Context, DispatchPhase, Element, GlobalElementId, Hitbox,
3 HitboxBehavior, InspectorElementId, IntoElement, LayoutId, ListState, MouseButton,
4 MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, Pixels, Point, Render, ScrollHandle,
5 Size, Style, Window, div, point, prelude::*, px, relative, size,
6};
7use liora_core::Config;
8use std::cell::Cell;
9
10thread_local! {
11 static VIRTUAL_SCROLLBAR_GRAB_OFFSET: Cell<Option<Pixels>> = const { Cell::new(None) };
12 static SCROLLBAR_GRAB_OFFSET: Cell<Option<Pixels>> = const { Cell::new(None) };
13}
14
15const SCROLLBAR_THUMB_WIDTH: Pixels = px(4.0);
16const SCROLLBAR_THUMB_HOVER_WIDTH: Pixels = px(8.0);
17const SCROLLBAR_HIT_WIDTH: Pixels = px(14.0);
18const SCROLLBAR_MIN_THUMB_HEIGHT: Pixels = px(24.0);
19
20pub struct Scrollbar {
21 scroll_handle: ScrollHandle,
22 render_content: Box<dyn Fn(&mut Window, &mut App) -> AnyElement + 'static>,
23 height: Option<Pixels>,
24}
25
26impl Scrollbar {
27 pub fn new<F, E>(_cx: &mut Context<Self>, render_content: F) -> Self
28 where
29 F: Fn(&mut Window, &mut App) -> E + 'static,
30 E: IntoElement,
31 {
32 Self {
33 scroll_handle: ScrollHandle::new(),
34 render_content: Box::new(move |window, cx| {
35 render_content(window, cx).into_any_element()
36 }),
37 height: None,
38 }
39 }
40
41 pub fn height(mut self, h: impl Into<Pixels>) -> Self {
42 self.height = Some(h.into());
43 self
44 }
45}
46
47pub struct VirtualScrollbar {
52 list_state: ListState,
53}
54
55impl VirtualScrollbar {
56 pub fn new(list_state: ListState) -> Self {
57 Self { list_state }
58 }
59}
60
61impl IntoElement for VirtualScrollbar {
62 type Element = Self;
63 fn into_element(self) -> Self::Element {
64 self
65 }
66}
67
68pub struct VirtualScrollbarPrepaint {
69 thumb_bounds: Option<Bounds<Pixels>>,
70 hover_bounds: Bounds<Pixels>,
71 hitbox: Hitbox,
72 active: bool,
73 dragging: bool,
74}
75
76#[derive(Clone, Copy)]
77struct ThumbMetrics {
78 bounds: Bounds<Pixels>,
79 max_offset: Pixels,
80 track_height: Pixels,
81}
82
83impl Element for VirtualScrollbar {
84 type RequestLayoutState = ();
85 type PrepaintState = VirtualScrollbarPrepaint;
86
87 fn id(&self) -> Option<gpui::ElementId> {
88 None
89 }
90
91 fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
92 None
93 }
94
95 fn request_layout(
96 &mut self,
97 _id: Option<&GlobalElementId>,
98 _id2: Option<&InspectorElementId>,
99 window: &mut Window,
100 cx: &mut App,
101 ) -> (LayoutId, Self::RequestLayoutState) {
102 let mut style = Style::default();
103 style.position = gpui::Position::Absolute;
104 style.size.width = relative(1.0).into();
105 style.size.height = relative(1.0).into();
106 (window.request_layout(style, [], cx), ())
107 }
108
109 fn prepaint(
110 &mut self,
111 _id: Option<&GlobalElementId>,
112 _id2: Option<&InspectorElementId>,
113 bounds: Bounds<Pixels>,
114 _request_layout: &mut Self::RequestLayoutState,
115 window: &mut Window,
116 _cx: &mut App,
117 ) -> Self::PrepaintState {
118 let metrics = virtual_thumb_metrics(&self.list_state, SCROLLBAR_THUMB_WIDTH);
119 let thumb = metrics.map(|metrics| metrics.bounds);
120 let hitbox_bounds = thumb.map(expand_scrollbar_hitbox).unwrap_or(Bounds {
121 origin: point(bounds.right() - SCROLLBAR_HIT_WIDTH, bounds.top()),
122 size: Size {
123 width: SCROLLBAR_HIT_WIDTH,
124 height: bounds.size.height,
125 },
126 });
127 let hitbox = window.insert_hitbox(hitbox_bounds, HitboxBehavior::Normal);
128 let dragging = virtual_scrollbar_grab_offset().is_some();
129 let active = hitbox.is_hovered(window)
130 || dragging
131 || hitbox_bounds.contains(&window.mouse_position());
132 let thumb_bounds = thumb.map(|thumb| {
133 let target_width = if active {
134 SCROLLBAR_THUMB_HOVER_WIDTH
135 } else {
136 SCROLLBAR_THUMB_WIDTH
137 };
138 scrollbar_thumb_bounds_for_width(thumb, target_width)
139 });
140 VirtualScrollbarPrepaint {
141 thumb_bounds,
142 hover_bounds: hitbox_bounds,
143 hitbox,
144 active,
145 dragging,
146 }
147 }
148
149 fn paint(
150 &mut self,
151 _id: Option<&GlobalElementId>,
152 _id2: Option<&InspectorElementId>,
153 _bounds: Bounds<Pixels>,
154 _request_layout: &mut Self::RequestLayoutState,
155 prepaint: &mut Self::PrepaintState,
156 window: &mut Window,
157 cx: &mut App,
158 ) {
159 let Some(thumb_bounds) = prepaint.thumb_bounds else {
160 return;
161 };
162
163 let thumb_color = cx
164 .global::<Config>()
165 .theme
166 .neutral
167 .border
168 .opacity(if prepaint.dragging { 1.0 } else { 0.8 });
169 window.paint_quad(PaintQuad {
170 bounds: thumb_bounds,
171 corner_radii: gpui::Corners::all(thumb_bounds.size.width / 2.0),
172 background: thumb_color.into(),
173 border_widths: gpui::Edges::all(px(0.0)),
174 border_color: gpui::transparent_black(),
175 border_style: gpui::BorderStyle::Solid,
176 });
177
178 let was_active = prepaint.active;
179 let hover_bounds = prepaint.hover_bounds;
180 let current_view = window.current_view();
181 window.on_mouse_event(move |_: &MouseMoveEvent, phase, window, cx| {
182 if phase == DispatchPhase::Capture {
183 let active = virtual_scrollbar_grab_offset().is_some()
184 || hover_bounds.contains(&window.mouse_position());
185 if active != was_active {
186 cx.notify(current_view);
187 window.refresh();
188 }
189 }
190 });
191
192 let list_state = self.list_state.clone();
193 let hitbox = prepaint.hitbox.clone();
194 let hover_bounds = prepaint.hover_bounds;
195 let raw_thumb_bounds = thumb_bounds;
196 window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| {
197 if phase == DispatchPhase::Capture
198 && event.button == MouseButton::Left
199 && (hitbox.is_hovered(window) || hover_bounds.contains(&event.position))
200 {
201 let grab_offset = if raw_thumb_bounds.contains(&event.position) {
202 event.position.y - raw_thumb_bounds.top()
203 } else {
204 raw_thumb_bounds.size.height / 2.0
205 };
206 set_virtual_scrollbar_grab_offset(Some(grab_offset));
207 list_state.scrollbar_drag_started();
208 set_virtual_scrollbar_position(&list_state, event.position, grab_offset);
209
210 cx.stop_propagation();
211 window.refresh();
212 }
213 });
214
215 let list_state = self.list_state.clone();
216 window.on_mouse_event(move |event: &MouseMoveEvent, phase, window, cx| {
217 if phase == DispatchPhase::Capture {
218 let Some(grab_offset) = virtual_scrollbar_grab_offset() else {
219 return;
220 };
221 set_virtual_scrollbar_position(&list_state, event.position, grab_offset);
222 cx.stop_propagation();
223 window.refresh();
224 }
225 });
226
227 let list_state = self.list_state.clone();
228 window.on_mouse_event(move |event: &MouseUpEvent, phase, window, cx| {
229 if phase == DispatchPhase::Capture
230 && event.button == MouseButton::Left
231 && virtual_scrollbar_grab_offset().is_some()
232 {
233 list_state.scrollbar_drag_ended();
234 set_virtual_scrollbar_grab_offset(None);
235 cx.stop_propagation();
236 window.refresh();
237 }
238 });
239 }
240}
241
242fn set_virtual_scrollbar_position(
243 list_state: &ListState,
244 position: Point<Pixels>,
245 grab_offset: Pixels,
246) {
247 let viewport = list_state.viewport_bounds();
248 let max_offset = list_state.max_offset_for_scrollbar();
249 if max_offset.height <= px(0.0) || viewport.size.height <= px(0.0) {
250 return;
251 }
252
253 let content_height = viewport.size.height + max_offset.height;
254 let thumb_height = (viewport.size.height * (viewport.size.height / content_height))
255 .max(SCROLLBAR_MIN_THUMB_HEIGHT)
256 .min(viewport.size.height);
257 let track_height = (viewport.size.height - thumb_height).max(px(1.0));
258 let y = (position.y - viewport.top() - grab_offset).clamp(px(0.0), track_height);
259 let content_offset = y / track_height * max_offset.height;
260 list_state.set_offset_from_scrollbar(point(px(0.0), content_offset));
261}
262
263fn virtual_thumb_metrics(list_state: &ListState, width: Pixels) -> Option<ThumbMetrics> {
264 let viewport = list_state.viewport_bounds();
265 let max_offset = list_state.max_offset_for_scrollbar();
266 let offset = list_state.scroll_px_offset_for_scrollbar();
267 let viewport_h = viewport.size.height;
268 let content_h = viewport_h + max_offset.height;
269
270 if content_h <= viewport_h || content_h <= px(0.0) || viewport_h <= px(0.0) {
271 return None;
272 }
273
274 let ratio = viewport_h / content_h;
275 let thumb_h = (viewport_h * ratio)
276 .max(SCROLLBAR_MIN_THUMB_HEIGHT)
277 .min(viewport_h);
278 let scroll_ratio = if max_offset.height > px(0.0) {
279 -offset.y / max_offset.height
280 } else {
281 0.0
282 }
283 .clamp(0.0, 1.0);
284 let thumb_top = (viewport_h - thumb_h) * scroll_ratio;
285
286 let bounds = Bounds {
287 origin: point(
288 viewport.right() - width - px(2.0),
289 viewport.top() + thumb_top,
290 ),
291 size: size(width, thumb_h),
292 };
293
294 Some(ThumbMetrics {
295 bounds,
296 max_offset: max_offset.height,
297 track_height: viewport_h - thumb_h,
298 })
299}
300
301fn scrollbar_thumb_bounds_for_width(target: Bounds<Pixels>, width: Pixels) -> Bounds<Pixels> {
302 Bounds {
303 origin: point(target.right() - width, target.top()),
304 size: size(width, target.size.height),
305 }
306}
307
308fn expand_scrollbar_hitbox(thumb: Bounds<Pixels>) -> Bounds<Pixels> {
309 Bounds {
310 origin: point(
311 thumb.right() - SCROLLBAR_HIT_WIDTH - px(2.0),
312 thumb.top() - px(4.0),
313 ),
314 size: size(SCROLLBAR_HIT_WIDTH + px(2.0), thumb.size.height + px(8.0)),
315 }
316}
317
318fn virtual_scrollbar_grab_offset() -> Option<Pixels> {
319 VIRTUAL_SCROLLBAR_GRAB_OFFSET.with(Cell::get)
320}
321
322fn set_virtual_scrollbar_grab_offset(offset: Option<Pixels>) {
323 VIRTUAL_SCROLLBAR_GRAB_OFFSET.with(|state| state.set(offset));
324}
325
326fn scrollbar_grab_offset() -> Option<Pixels> {
327 SCROLLBAR_GRAB_OFFSET.with(Cell::get)
328}
329
330fn set_scrollbar_grab_offset(offset: Option<Pixels>) {
331 SCROLLBAR_GRAB_OFFSET.with(|state| state.set(offset));
332}
333
334fn scroll_handle_thumb_metrics(
335 scroll_handle: &ScrollHandle,
336 width: Pixels,
337) -> Option<ThumbMetrics> {
338 let viewport_bounds = scroll_handle.bounds();
339 let max_offset = scroll_handle.max_offset();
340 let offset = scroll_handle.offset();
341
342 let viewport_h = viewport_bounds.size.height;
343 let content_h = viewport_h + max_offset.height;
344
345 if content_h <= viewport_h || content_h <= px(0.0) || viewport_h <= px(0.0) {
346 return None;
347 }
348
349 let ratio = viewport_h / content_h;
350 let thumb_h = (viewport_h * ratio)
351 .max(SCROLLBAR_MIN_THUMB_HEIGHT)
352 .min(viewport_h);
353 let scroll_ratio = if max_offset.height > px(0.0) {
354 -offset.y / max_offset.height
355 } else {
356 0.0
357 }
358 .clamp(0.0, 1.0);
359 let thumb_top = (viewport_h - thumb_h) * scroll_ratio;
360 let bounds = Bounds {
361 origin: point(
362 viewport_bounds.right() - width - px(2.0),
363 viewport_bounds.top() + thumb_top,
364 ),
365 size: size(width, thumb_h),
366 };
367
368 Some(ThumbMetrics {
369 bounds,
370 max_offset: max_offset.height,
371 track_height: viewport_h - thumb_h,
372 })
373}
374
375fn set_scroll_handle_position(
376 scroll_handle: &ScrollHandle,
377 position: Point<Pixels>,
378 grab_offset: Pixels,
379) {
380 let Some(metrics) = scroll_handle_thumb_metrics(scroll_handle, SCROLLBAR_THUMB_WIDTH) else {
381 return;
382 };
383 if metrics.max_offset <= px(0.0) || metrics.track_height <= px(0.0) {
384 return;
385 }
386
387 let viewport = scroll_handle.bounds();
388 let y = (position.y - viewport.top() - grab_offset).clamp(px(0.0), metrics.track_height);
389 let content_offset = y / metrics.track_height * metrics.max_offset;
390 scroll_handle.set_offset(point(px(0.0), -content_offset));
391}
392
393impl Render for Scrollbar {
394 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
395 let scroll_handle = self.scroll_handle.clone();
396 let content = (self.render_content)(_window, cx);
397
398 let mut container = div().flex().flex_col().overflow_hidden();
399 if let Some(h) = self.height {
400 container = container.h(h);
401 } else {
402 container = container.h_full();
403 }
404
405 container
406 .relative()
407 .child(
408 div()
409 .flex_1()
410 .id("scroll-viewport")
411 .overflow_y_scroll()
412 .track_scroll(&scroll_handle)
413 .on_scroll_wheel(cx.listener(|_, _, _, cx| {
414 cx.notify();
415 }))
416 .child(content),
417 )
418 .child(ScrollbarThumb {
419 scroll_handle: self.scroll_handle.clone(),
420 })
421 }
422}
423
424struct ScrollbarThumb {
425 scroll_handle: ScrollHandle,
426}
427
428struct ScrollbarThumbPrepaint {
429 thumb_bounds: Option<Bounds<Pixels>>,
430 hover_bounds: Bounds<Pixels>,
431 hitbox: Hitbox,
432 active: bool,
433 dragging: bool,
434}
435
436impl IntoElement for ScrollbarThumb {
437 type Element = Self;
438 fn into_element(self) -> Self::Element {
439 self
440 }
441}
442
443impl gpui::Element for ScrollbarThumb {
444 type RequestLayoutState = ();
445 type PrepaintState = ScrollbarThumbPrepaint;
446
447 fn id(&self) -> Option<gpui::ElementId> {
448 None
449 }
450
451 fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
452 None
453 }
454
455 fn request_layout(
456 &mut self,
457 _id: Option<&gpui::GlobalElementId>,
458 _id2: Option<&gpui::InspectorElementId>,
459 window: &mut Window,
460 cx: &mut App,
461 ) -> (gpui::LayoutId, Self::RequestLayoutState) {
462 let mut style = gpui::Style::default();
463 style.position = gpui::Position::Absolute;
464 style.size.width = gpui::relative(1.0).into();
465 style.size.height = gpui::relative(1.0).into();
466 (window.request_layout(style, [], cx), ())
467 }
468
469 fn prepaint(
470 &mut self,
471 _id: Option<&gpui::GlobalElementId>,
472 _id2: Option<&gpui::InspectorElementId>,
473 bounds: gpui::Bounds<Pixels>,
474 _request_layout: &mut Self::RequestLayoutState,
475 window: &mut Window,
476 _cx: &mut App,
477 ) -> Self::PrepaintState {
478 let metrics = scroll_handle_thumb_metrics(&self.scroll_handle, SCROLLBAR_THUMB_WIDTH);
479 let thumb = metrics.map(|metrics| metrics.bounds);
480 let hover_bounds = thumb.map(expand_scrollbar_hitbox).unwrap_or(Bounds {
481 origin: point(bounds.right() - SCROLLBAR_HIT_WIDTH, bounds.top()),
482 size: Size {
483 width: SCROLLBAR_HIT_WIDTH,
484 height: bounds.size.height,
485 },
486 });
487 let hitbox = window.insert_hitbox(hover_bounds, HitboxBehavior::Normal);
488 let dragging = scrollbar_grab_offset().is_some();
489 let active = hitbox.is_hovered(window)
490 || dragging
491 || hover_bounds.contains(&window.mouse_position());
492 let thumb_bounds = thumb.map(|thumb| {
493 let target_width = if active {
494 SCROLLBAR_THUMB_HOVER_WIDTH
495 } else {
496 SCROLLBAR_THUMB_WIDTH
497 };
498 scrollbar_thumb_bounds_for_width(thumb, target_width)
499 });
500
501 ScrollbarThumbPrepaint {
502 thumb_bounds,
503 hover_bounds,
504 hitbox,
505 active,
506 dragging,
507 }
508 }
509
510 fn paint(
511 &mut self,
512 _id: Option<&gpui::GlobalElementId>,
513 _id2: Option<&gpui::InspectorElementId>,
514 _bounds: gpui::Bounds<Pixels>,
515 _request_layout: &mut Self::RequestLayoutState,
516 prepaint: &mut Self::PrepaintState,
517 window: &mut Window,
518 cx: &mut App,
519 ) -> () {
520 let Some(thumb_bounds) = prepaint.thumb_bounds else {
521 return;
522 };
523
524 let thumb_color = cx
525 .global::<Config>()
526 .theme
527 .neutral
528 .border
529 .opacity(if prepaint.dragging { 1.0 } else { 0.8 });
530
531 let was_active = prepaint.active;
532 let hover_bounds = prepaint.hover_bounds;
533 let current_view = window.current_view();
534 window.on_mouse_event(move |_: &MouseMoveEvent, phase, window, cx| {
535 if phase == DispatchPhase::Capture {
536 let active = scrollbar_grab_offset().is_some()
537 || hover_bounds.contains(&window.mouse_position());
538 if active != was_active {
539 cx.notify(current_view);
540 window.refresh();
541 }
542 }
543 });
544
545 let scroll_handle = self.scroll_handle.clone();
546 let hitbox = prepaint.hitbox.clone();
547 let hover_bounds = prepaint.hover_bounds;
548 let raw_thumb_bounds = thumb_bounds;
549 window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| {
550 if phase == DispatchPhase::Capture
551 && event.button == MouseButton::Left
552 && (hitbox.is_hovered(window) || hover_bounds.contains(&event.position))
553 {
554 let grab_offset = if raw_thumb_bounds.contains(&event.position) {
555 event.position.y - raw_thumb_bounds.top()
556 } else {
557 raw_thumb_bounds.size.height / 2.0
558 };
559 set_scrollbar_grab_offset(Some(grab_offset));
560 set_scroll_handle_position(&scroll_handle, event.position, grab_offset);
561
562 cx.stop_propagation();
563 window.refresh();
564 }
565 });
566
567 let scroll_handle = self.scroll_handle.clone();
568 window.on_mouse_event(move |event: &MouseMoveEvent, phase, window, cx| {
569 if phase == DispatchPhase::Capture {
570 let Some(grab_offset) = scrollbar_grab_offset() else {
571 return;
572 };
573 set_scroll_handle_position(&scroll_handle, event.position, grab_offset);
574 cx.stop_propagation();
575 window.refresh();
576 }
577 });
578
579 window.on_mouse_event(move |event: &MouseUpEvent, phase, window, cx| {
580 if phase == DispatchPhase::Capture
581 && event.button == MouseButton::Left
582 && scrollbar_grab_offset().is_some()
583 {
584 set_scrollbar_grab_offset(None);
585 cx.stop_propagation();
586 window.refresh();
587 }
588 });
589
590 window.paint_quad(gpui::PaintQuad {
591 bounds: thumb_bounds,
592 corner_radii: gpui::Corners::all(thumb_bounds.size.width / 2.0),
593 background: thumb_color.into(),
594 border_widths: gpui::Edges::all(gpui::px(0.0)),
595 border_color: gpui::hsla(0.0, 0.0, 0.0, 0.0),
596 border_style: gpui::BorderStyle::Solid,
597 });
598 }
599}
600
601#[cfg(test)]
602mod tests {
603 #[test]
604 fn virtual_scrollbar_bootstraps_gpui_list_state_scrolling() {
605 let source = include_str!("scrollbar.rs");
606
607 assert!(source.contains("pub struct VirtualScrollbar"));
608 assert!(source.contains("ListState"));
609 assert!(source.contains("scroll_px_offset_for_scrollbar"));
610 assert!(source.contains("max_offset_for_scrollbar"));
611 assert!(source.contains("set_offset_from_scrollbar"));
612 assert!(source.contains("scrollbar_drag_started"));
613 assert!(source.contains("scrollbar_drag_ended"));
614 assert!(source.contains("virtual_scrollbar_grab_offset"));
615 }
616
617 #[test]
618 fn scrollbars_expand_on_hover_and_drag_without_smoothing() {
619 let source = include_str!("scrollbar.rs")
620 .split("#[cfg(test)]")
621 .next()
622 .expect("production source should precede tests");
623
624 assert!(source.contains("SCROLLBAR_THUMB_HOVER_WIDTH"));
625 assert!(source.contains("SCROLLBAR_HIT_WIDTH"));
626 assert!(source.contains("scrollbar_thumb_bounds_for_width"));
627 assert!(source.contains("set_scrollbar_grab_offset"));
628 assert!(source.contains("set_virtual_scrollbar_grab_offset"));
629 assert!(source.contains("set_scroll_handle_position"));
630 assert!(source.contains("set_virtual_scrollbar_position"));
631 assert!(source.contains("hover_bounds.contains(&window.mouse_position())"));
632 assert!(source.contains("cx.notify(current_view)"));
633 assert!(!source.contains("lerp_pixels"));
634 assert!(!source.contains("request_animation_frame"));
635 }
636}