Skip to main content

liora_components/
scrollbar.rs

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
47/// Paints and drives a scrollbar for GPUI's virtual [`ListState`].
48///
49/// This lets Liora Docs use GPUI's native virtual list for visible-area rendering
50/// while still bootstrapping the visual scrollbar from Liora's component layer.
51pub 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}