Skip to main content

ui/scroll/
overlay.rs

1//! Stateful overlays for either scroll axis.
2
3use super::{self as scroll, ScrollbarState, TransientState};
4use gpui::{
5    self, Animation, AnimationExt, AnyElement, App, Axis, Div, DragMoveEvent, Empty, Global,
6    IntoElement, MouseButton, Pixels, RenderOnce, ScrollHandle, SharedString, Stateful, Window,
7    canvas, div, point, prelude::*, px,
8};
9use motion::Painter;
10use std::{cell::Cell, rc::Rc};
11use theme::ink;
12
13/// Visibility for overflowing panes; content that fits never draws a bar.
14#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
15pub enum Visibility {
16    #[default]
17    Scrolling,
18    Always,
19    Never,
20}
21impl Global for Visibility {}
22
23pub fn visibility(cx: &App) -> Visibility {
24    cx.try_global::<Visibility>().copied().unwrap_or_default()
25}
26
27/// Set the default for overlays, including those inside Markdown blocks.
28pub fn set_visibility(value: Visibility, cx: &mut App) {
29    cx.set_global(value);
30    cx.refresh_windows();
31}
32
33#[derive(IntoElement)]
34pub struct Overlay {
35    id: SharedString,
36    handle: ScrollHandle,
37    axis: Axis,
38    visibility: Option<Visibility>,
39    place: scroll::Place,
40}
41
42impl Overlay {
43    /// Mount beside the scroller in a relative wrapper of the same size.
44    pub fn new(id: impl Into<SharedString>, handle: &ScrollHandle, axis: Axis) -> Self {
45        Self {
46            id: id.into(),
47            handle: handle.clone(),
48            axis,
49            visibility: None,
50            place: scroll::Place::default(),
51        }
52    }
53
54    /// Shorten the track to clear an overlaid footer without resizing content.
55    pub fn end_inset(mut self, inset: Pixels) -> Self {
56        self.place.end = inset.max(px(0.));
57        self
58    }
59
60    /// Gap between the pane's edge and the thumb, across the axis. Defaults
61    /// to 2px. The grab area stays the same width at any margin.
62    pub fn margin(mut self, margin: Pixels) -> Self {
63        self.place.margin = margin.max(px(0.));
64        self
65    }
66
67    /// Override the default for an individual pane, such as a sidebar.
68    pub fn visibility(mut self, visibility: Visibility) -> Self {
69        self.visibility = Some(visibility);
70        self
71    }
72}
73
74/// An intrinsically sized scroll container with its own handle and overlay.
75#[derive(IntoElement)]
76pub struct Viewport {
77    id: SharedString,
78    content: Stateful<Div>,
79    axis: Axis,
80    fill: bool,
81    handle: Option<ScrollHandle>,
82}
83
84impl Viewport {
85    pub fn new(id: impl Into<SharedString>, content: Stateful<Div>, axis: Axis) -> Self {
86        Self {
87            id: id.into(),
88            content,
89            axis,
90            fill: false,
91            handle: None,
92        }
93    }
94
95    /// Fill the remaining space in a flex container instead of sizing to content.
96    pub fn fill(mut self) -> Self {
97        self.fill = true;
98        self
99    }
100
101    /// Scroll it from outside — what a caller needs to ask
102    /// [`gpui::ScrollHandle::scroll_to_item`] for one of its children. Left
103    /// unset, the viewport keeps a handle of its own that nothing else can
104    /// reach.
105    pub fn track_scroll(mut self, handle: &ScrollHandle) -> Self {
106        self.handle = Some(handle.clone());
107        self
108    }
109}
110
111impl RenderOnce for Viewport {
112    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
113        let handle = match self.handle {
114            Some(handle) => handle,
115            None => window
116                .use_keyed_state(
117                    SharedString::from(format!("{}-handle", self.id)),
118                    cx,
119                    |_, _| ScrollHandle::new(),
120                )
121                .read(cx)
122                .clone(),
123        };
124        let axes = match self.axis {
125            Axis::Vertical => scroll::Axes::Vertical,
126            Axis::Horizontal => scroll::Axes::Horizontal,
127        };
128        div()
129            .relative()
130            .w_full()
131            .min_w_0()
132            .when(self.fill, |el| el.flex_1().min_h_0().flex().flex_col())
133            .child(scroll::scrolls(self.content, axes).track_scroll(&handle))
134            .child(Overlay::new(self.id, &handle, self.axis))
135    }
136}
137
138struct State {
139    steady: ScrollbarState,
140    transient: TransientState,
141    horizontal: Rc<Cell<Horizontal>>,
142    horizontal_hover: Rc<Cell<scroll::Hover>>,
143}
144
145#[derive(Clone, Copy, Default)]
146struct Horizontal {
147    offset: Pixels,
148    max: Pixels,
149    generation: usize,
150    grab: Option<Pixels>,
151}
152
153impl RenderOnce for Overlay {
154    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
155        let mode = self.visibility.unwrap_or_else(|| visibility(cx));
156        if mode == Visibility::Never {
157            return Empty.into_any_element();
158        }
159        let state = window.use_keyed_state(
160            SharedString::from(format!("{}-state", self.id)),
161            cx,
162            |_, cx| State {
163                steady: ScrollbarState::new(Painter::of(cx)),
164                transient: TransientState::new(Painter::of(cx)),
165                horizontal: Rc::default(),
166                horizontal_hover: Rc::default(),
167            },
168        );
169        let held = state.read(cx);
170        let always = mode == Visibility::Always || cx.reduce_motion();
171        let inner = match self.axis {
172            Axis::Vertical if always => {
173                scroll::scrollbar_placed(self.id, &self.handle, &held.steady, self.place)
174            }
175            Axis::Vertical => {
176                scroll::transient_placed(self.id, &self.handle, &held.transient, false, self.place)
177            }
178            Axis::Horizontal => horizontal(
179                self.id,
180                &self.handle,
181                held.horizontal.clone(),
182                held.horizontal_hover.clone(),
183                always,
184                self.place,
185            ),
186        };
187        let handle = self.handle;
188        let before = (handle.bounds(), handle.max_offset(), handle.offset());
189        // Handles receive new geometry during layout, after this render pass.
190        div()
191            .absolute()
192            .inset_0()
193            .child(inner)
194            .child(
195                canvas(
196                    move |_, window, _| {
197                        if before != (handle.bounds(), handle.max_offset(), handle.offset()) {
198                            window.request_animation_frame();
199                        }
200                    },
201                    |_, _, _, _| {},
202                )
203                .absolute()
204                .size_full(),
205            )
206            .into_any_element()
207    }
208}
209
210#[derive(Clone)]
211struct HorizontalDrag(SharedString);
212
213fn horizontal(
214    id: SharedString,
215    handle: &ScrollHandle,
216    state: Rc<Cell<Horizontal>>,
217    hover: Rc<Cell<scroll::Hover>>,
218    always: bool,
219    place: scroll::Place,
220) -> AnyElement {
221    let end_inset = place.end;
222    let viewport = handle.bounds().size.width;
223    let max = handle.max_offset().x;
224    let Some(range) = scroll::thumb_in_track(
225        viewport,
226        max,
227        handle.offset().x,
228        viewport - 2. * scroll::BAR_INSET - end_inset,
229    ) else {
230        return Empty.into_any_element();
231    };
232    let size = range.end - range.start;
233    let mut held = state.get();
234    if (held.offset - handle.offset().x).abs() > px(0.5) || (held.max - max).abs() > px(0.5) {
235        held.offset = handle.offset().x;
236        held.max = max;
237        held.generation += 1;
238        state.set(held);
239    }
240    let drag_id = id.clone();
241    let drag_handle = handle.clone();
242    let drag_state = state.clone();
243    let release_state = state.clone();
244    let release = move |_: &gpui::MouseUpEvent, window: &mut Window, _: &mut App| {
245        let mut held = release_state.get();
246        held.grab = None;
247        held.generation += 1;
248        release_state.set(held);
249        window.refresh();
250    };
251    let hover_state = state.clone();
252    let track = scroll::track(&id, place, Axis::Horizontal);
253    let track = scroll::hover_track(track, hover.clone(), move |window, _| {
254        let mut held = hover_state.get();
255        held.generation += 1;
256        hover_state.set(held);
257        window.refresh();
258    })
259    .on_drag_move(move |event: &DragMoveEvent<HorizontalDrag>, window, cx| {
260        if event.drag(cx).0 != drag_id {
261            return;
262        }
263        let viewport = drag_handle.bounds().size.width;
264        let max = drag_handle.max_offset().x;
265        let Some(range) = scroll::thumb_in_track(
266            viewport,
267            max,
268            drag_handle.offset().x,
269            viewport - 2. * scroll::BAR_INSET - end_inset,
270        ) else {
271            return;
272        };
273        let pointer = event.event.position.x - event.bounds.left();
274        let mut held = drag_state.get();
275        let grab = *held
276            .grab
277            .get_or_insert((pointer - range.start).clamp(px(0.), range.end - range.start));
278        drag_state.set(held);
279        let x = scroll::offset_for_thumb(
280            pointer - grab,
281            viewport - 2. * scroll::BAR_INSET - end_inset,
282            max,
283            range.end - range.start,
284        );
285        drag_handle.set_offset(point(x, drag_handle.offset().y));
286        window.refresh();
287    })
288    .on_mouse_up(MouseButton::Left, release.clone())
289    .on_mouse_up_out(MouseButton::Left, release);
290    let thumb_debug_id = id.clone();
291    let press_state = state.clone();
292    let press_handle = handle.clone();
293    let thumb = div()
294        .debug_selector(move || format!("{thumb_debug_id}-thumb"))
295        .id(SharedString::from(format!("{id}-thumb")))
296        .absolute()
297        .left(range.start)
298        .w(size)
299        .bottom(place.inner())
300        .h(px(scroll::THUMB))
301        .rounded_full()
302        .bg(ink(0.2))
303        .hover(|s| s.bg(ink(0.32)))
304        .on_mouse_down(MouseButton::Left, move |event, window, _| {
305            let mut held = press_state.get();
306            held.grab = Some(
307                (event.position.x - press_handle.bounds().left() - scroll::BAR_INSET - range.start)
308                    .clamp(px(0.), size),
309            );
310            press_state.set(held);
311            window.refresh();
312        })
313        .on_drag(HorizontalDrag(id.clone()), |_, _, _, cx| cx.new(|_| Empty));
314    let thumb = if always {
315        thumb.into_any_element()
316    } else {
317        thumb
318            .with_animation(
319                SharedString::from(format!("{id}-fade-{}", held.generation)),
320                Animation::new(scroll::TRANSIENT_IDLE),
321                move |el, p| {
322                    if hover.get().held() || state.get().grab.is_some() {
323                        el
324                    } else if p < 1. {
325                        el.opacity(1. - p)
326                    } else {
327                        el.hidden()
328                    }
329                },
330            )
331            .into_any_element()
332    };
333    track.child(thumb).into_any_element()
334}