Skip to main content

ui/scroll/
overlay.rs

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