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    /// Centre the bar in `room` reserved across its axis rather than in the
61    /// default strip at the edge. Pass the padding the pane holds beside its
62    /// content and the thumb runs down the middle of it.
63    pub fn channel(mut self, room: Pixels) -> Self {
64        self.place.channel = room.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}
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        }
92    }
93
94    /// Fill the remaining space in a flex container instead of sizing to content.
95    pub fn fill(mut self) -> Self {
96        self.fill = true;
97        self
98    }
99}
100
101impl RenderOnce for Viewport {
102    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
103        let state = window.use_keyed_state(
104            SharedString::from(format!("{}-handle", self.id)),
105            cx,
106            |_, _| ScrollHandle::new(),
107        );
108        let handle = state.read(cx).clone();
109        let axes = match self.axis {
110            Axis::Vertical => scroll::Axes::Vertical,
111            Axis::Horizontal => scroll::Axes::Horizontal,
112        };
113        div()
114            .relative()
115            .w_full()
116            .min_w_0()
117            .when(self.fill, |el| el.flex_1().min_h_0().flex().flex_col())
118            .child(scroll::scrolls(self.content, axes).track_scroll(&handle))
119            .child(Overlay::new(self.id, &handle, self.axis))
120    }
121}
122
123struct State {
124    steady: ScrollbarState,
125    transient: TransientState,
126    horizontal: Rc<Cell<Horizontal>>,
127}
128
129#[derive(Clone, Copy, Default)]
130struct Horizontal {
131    offset: Pixels,
132    max: Pixels,
133    generation: usize,
134    hovered: bool,
135    grab: Option<Pixels>,
136}
137
138impl RenderOnce for Overlay {
139    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
140        let mode = self.visibility.unwrap_or_else(|| visibility(cx));
141        if mode == Visibility::Never {
142            return Empty.into_any_element();
143        }
144        let state = window.use_keyed_state(
145            SharedString::from(format!("{}-state", self.id)),
146            cx,
147            |_, cx| State {
148                steady: ScrollbarState::new(Painter::of(cx)),
149                transient: TransientState::new(Painter::of(cx)),
150                horizontal: Rc::default(),
151            },
152        );
153        let held = state.read(cx);
154        let always = mode == Visibility::Always || cx.reduce_motion();
155        let inner = match self.axis {
156            Axis::Vertical if always => {
157                scroll::scrollbar_placed(self.id, &self.handle, &held.steady, self.place)
158            }
159            Axis::Vertical => {
160                scroll::transient_placed(self.id, &self.handle, &held.transient, false, self.place)
161            }
162            Axis::Horizontal => horizontal(
163                self.id,
164                &self.handle,
165                held.horizontal.clone(),
166                always,
167                self.place,
168            ),
169        };
170        let handle = self.handle;
171        let before = (handle.bounds(), handle.max_offset(), handle.offset());
172        // Handles receive new geometry during layout, after this render pass.
173        div()
174            .absolute()
175            .inset_0()
176            .child(inner)
177            .child(
178                canvas(
179                    move |_, window, _| {
180                        if before != (handle.bounds(), handle.max_offset(), handle.offset()) {
181                            window.request_animation_frame();
182                        }
183                    },
184                    |_, _, _, _| {},
185                )
186                .absolute()
187                .size_full(),
188            )
189            .into_any_element()
190    }
191}
192
193#[derive(Clone)]
194struct HorizontalDrag(SharedString);
195
196fn horizontal(
197    id: SharedString,
198    handle: &ScrollHandle,
199    state: Rc<Cell<Horizontal>>,
200    always: bool,
201    place: scroll::Place,
202) -> AnyElement {
203    let end_inset = place.end;
204    let viewport = handle.bounds().size.width;
205    let max = handle.max_offset().x;
206    let Some(range) = scroll::thumb_in_track(
207        viewport,
208        max,
209        handle.offset().x,
210        viewport - 2. * scroll::BAR_INSET - end_inset,
211    ) else {
212        return Empty.into_any_element();
213    };
214    let size = range.end - range.start;
215    let mut held = state.get();
216    if (held.offset - handle.offset().x).abs() > px(0.5) || (held.max - max).abs() > px(0.5) {
217        held.offset = handle.offset().x;
218        held.max = max;
219        held.generation += 1;
220        state.set(held);
221    }
222    let drag_id = id.clone();
223    let drag_handle = handle.clone();
224    let drag_state = state.clone();
225    let release_state = state.clone();
226    let release = move |_: &gpui::MouseUpEvent, window: &mut Window, _: &mut App| {
227        let mut held = release_state.get();
228        held.grab = None;
229        held.generation += 1;
230        release_state.set(held);
231        window.refresh();
232    };
233    let hover_state = state.clone();
234    let track = scroll::track(&id, place, Axis::Horizontal)
235        .on_hover(move |hovered, window, _| {
236            let mut held = hover_state.get();
237            held.hovered = *hovered;
238            held.generation += 1;
239            hover_state.set(held);
240            window.refresh();
241        })
242        .on_drag_move(move |event: &DragMoveEvent<HorizontalDrag>, window, cx| {
243            if event.drag(cx).0 != drag_id {
244                return;
245            }
246            let viewport = drag_handle.bounds().size.width;
247            let max = drag_handle.max_offset().x;
248            let Some(range) = scroll::thumb_in_track(
249                viewport,
250                max,
251                drag_handle.offset().x,
252                viewport - 2. * scroll::BAR_INSET - end_inset,
253            ) else {
254                return;
255            };
256            let pointer = event.event.position.x - event.bounds.left();
257            let mut held = drag_state.get();
258            let grab = *held
259                .grab
260                .get_or_insert((pointer - range.start).clamp(px(0.), range.end - range.start));
261            drag_state.set(held);
262            let x = scroll::offset_for_thumb(
263                pointer - grab,
264                viewport - 2. * scroll::BAR_INSET - end_inset,
265                max,
266                range.end - range.start,
267            );
268            drag_handle.set_offset(point(x, drag_handle.offset().y));
269            window.refresh();
270        })
271        .on_mouse_up(MouseButton::Left, release.clone())
272        .on_mouse_up_out(MouseButton::Left, release);
273    let thumb_debug_id = id.clone();
274    let press_state = state.clone();
275    let press_handle = handle.clone();
276    let thumb = div()
277        .debug_selector(move || format!("{thumb_debug_id}-thumb"))
278        .id(SharedString::from(format!("{id}-thumb")))
279        .absolute()
280        .left(range.start)
281        .w(size)
282        .h(px(scroll::THUMB))
283        .rounded_full()
284        .bg(ink(0.2))
285        .hover(|s| s.bg(ink(0.32)))
286        .on_mouse_down(MouseButton::Left, move |event, window, _| {
287            let mut held = press_state.get();
288            held.grab = Some(
289                (event.position.x - press_handle.bounds().left() - scroll::BAR_INSET - range.start)
290                    .clamp(px(0.), size),
291            );
292            press_state.set(held);
293            window.refresh();
294        })
295        .on_drag(HorizontalDrag(id.clone()), |_, _, _, cx| cx.new(|_| Empty));
296    let thumb = if always {
297        thumb.into_any_element()
298    } else {
299        thumb
300            .with_animation(
301                SharedString::from(format!("{id}-fade-{}", held.generation)),
302                Animation::new(scroll::TRANSIENT_IDLE),
303                move |el, p| {
304                    let held = state.get();
305                    if held.hovered || held.grab.is_some() {
306                        el
307                    } else if p < 1. {
308                        el.opacity(1. - p)
309                    } else {
310                        el.hidden()
311                    }
312                },
313            )
314            .into_any_element()
315    };
316    track.child(thumb).into_any_element()
317}