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