Skip to main content

herogpui_components/
scroll_shadow.rs

1//! ScrollShadow — port of `@heroui/scroll-shadow` (v3).
2//!
3//! A scrollable container with soft fading edges. Mirrors the React API:
4//! `orientation`, `variant`, `size`, `offset`, `hideScrollBar`, `isEnabled`
5//! and `visibility`.
6
7use gpui::{
8    canvas, div, prelude::*, px, AnyElement, App, ElementId, IntoElement, ParentElement, Pixels,
9    RenderOnce, ScrollHandle, Styled, Window,
10};
11use herogpui_core::{element_id, Orientation};
12use herogpui_theme::ActiveTheme;
13
14/// The shadow effect style. v3 ships one.
15#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
16pub enum ScrollShadowVariant {
17    /// A fading shadow.
18    #[default]
19    Fade,
20}
21
22/// Which edges show a shadow (`visibility`).
23///
24/// `Auto` is derived from the live scroll offset: a tracked `ScrollHandle`
25/// reports where the content sits, so the leading fade appears once it has been
26/// scrolled away from the start and the trailing one goes when the end is
27/// reached. The offset is a frame behind -- gpui fills the handle during
28/// prepaint -- which `onVisibilityChange` reports from a canvas in the same
29/// frame.
30#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
31pub enum ScrollShadowVisibility {
32    /// Derived from the scroll position.
33    #[default]
34    Auto,
35    /// Both edges of the scroll axis.
36    Both,
37    /// Shadow at the top edge only.
38    Top,
39    /// Shadow at the bottom edge only.
40    Bottom,
41    /// Shadow at the left edge only.
42    Left,
43    /// Shadow at the right edge only.
44    Right,
45    /// No shadow.
46    None,
47}
48
49impl ScrollShadowVisibility {
50    /// Whether the leading edge (top / left) should be shaded.
51    fn shows_start(self, orientation: Orientation) -> bool {
52        match self {
53            // `Auto` is resolved before this is asked; `Both` is unconditional.
54            Self::Auto | Self::Both => true,
55            Self::Top => !orientation.is_horizontal(),
56            Self::Left => orientation.is_horizontal(),
57            _ => false,
58        }
59    }
60
61    /// Whether the trailing edge (bottom / right) should be shaded.
62    fn shows_end(self, orientation: Orientation) -> bool {
63        match self {
64            Self::Auto | Self::Both => true,
65            Self::Bottom => !orientation.is_horizontal(),
66            Self::Right => orientation.is_horizontal(),
67            _ => false,
68        }
69    }
70}
71
72/// HeroUI ScrollShadow.
73#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
74#[derive(IntoElement)]
75pub struct ScrollShadow {
76    id: ElementId,
77    orientation: Orientation,
78    /// Gradient depth in pixels (`size`, default 40).
79    size: Pixels,
80    /// Scroll distance before the shadow appears (`offset`).
81    offset: Pixels,
82    is_enabled: bool,
83    visibility: ScrollShadowVisibility,
84    /// `onVisibilityChange` — reports the edges that are shaded, whenever that
85    /// changes.
86    on_visibility_change:
87        Option<std::sync::Arc<dyn Fn(&ScrollShadowVisibility, &mut Window, &mut App) + 'static>>,
88    max_h: Option<Pixels>,
89    max_w: Option<Pixels>,
90    gap: Pixels,
91    children: Vec<AnyElement>,
92    hide_scroll_bar: bool,
93    /// Makes the scroll axis fill its parent. Standalone shadows keep their
94    /// intrinsic/max-constrained sizing; composed controls opt into a bounded
95    /// viewport when they own an adjacent panel.
96    fill_axis: bool,
97    /// An owner can provide its existing scroll handle so edge shadows and
98    /// external controls observe the same offset and max range.
99    external_scroll: Option<ScrollHandle>,
100    /// The `sx` slot, refined over the root style at the end of render.
101    sx: Option<Box<gpui::StyleRefinement>>,
102}
103
104impl ScrollShadow {
105    /// Creates a scroll shadow with the given element id.
106    pub fn new(id: impl Into<ElementId>) -> Self {
107        Self {
108            id: id.into(),
109            orientation: Orientation::Vertical,
110            size: px(40.),
111            offset: px(0.),
112            is_enabled: true,
113            visibility: ScrollShadowVisibility::Auto,
114            on_visibility_change: None,
115            max_h: Some(px(240.)),
116            max_w: None,
117            gap: px(8.),
118            children: Vec::new(),
119            hide_scroll_bar: false,
120            fill_axis: false,
121            external_scroll: None,
122            sx: None,
123        }
124    }
125
126    /// `hideScrollBar` — omit the painted overlay thumb. GPUI has no native
127    /// scrollbar on a overflow div; this is the port of v3's CSS hide.
128    pub fn hide_scroll_bar(mut self, v: bool) -> Self {
129        self.hide_scroll_bar = v;
130        self
131    }
132
133    /// Reuses a caller-owned scroll handle for composed controls.
134    pub(crate) fn scroll_handle(mut self, handle: ScrollHandle) -> Self {
135        self.external_scroll = Some(handle);
136        self
137    }
138
139    /// Sets the scroll direction (v3 `orientation`).
140    pub fn orientation(mut self, orientation: Orientation) -> Self {
141        self.orientation = orientation;
142        self
143    }
144
145    /// Gradient depth in pixels.
146    pub fn size(mut self, size: impl Into<Pixels>) -> Self {
147        self.size = size.into();
148        self
149    }
150
151    /// Sets the scroll distance before the shadow appears (v3 `offset`).
152    pub fn offset(mut self, offset: impl Into<Pixels>) -> Self {
153        self.offset = offset.into();
154        self
155    }
156
157    /// Turns shadow rendering off while keeping the scroll behaviour.
158    pub fn is_enabled(mut self, v: bool) -> Self {
159        self.is_enabled = v;
160        self
161    }
162
163    /// `onVisibilityChange` — fires when the shaded edges change, with the
164    /// resolved visibility (never `Auto`: the resolved value is what changed).
165    pub fn on_visibility_change(
166        mut self,
167        handler: impl Fn(&ScrollShadowVisibility, &mut Window, &mut App) + 'static,
168    ) -> Self {
169        self.on_visibility_change = Some(std::sync::Arc::new(handler));
170        self
171    }
172
173    /// Sets which edges show a shadow (v3 `visibility`).
174    pub fn visibility(mut self, visibility: ScrollShadowVisibility) -> Self {
175        self.visibility = visibility;
176        self
177    }
178
179    /// Removes the standalone 240px height cap when the caller supplies a
180    /// full-height viewport.
181    pub(crate) fn unbounded_h(mut self) -> Self {
182        self.max_h = None;
183        self
184    }
185
186    pub(crate) fn fill_axis(mut self, value: bool) -> Self {
187        self.fill_axis = value;
188        self
189    }
190
191    /// Sets the maximum height.
192    pub fn max_h(mut self, v: impl Into<Pixels>) -> Self {
193        self.max_h = Some(v.into());
194        self
195    }
196
197    /// Sets the maximum width.
198    pub fn max_w(mut self, v: impl Into<Pixels>) -> Self {
199        self.max_w = Some(v.into());
200        self
201    }
202
203    /// Gap between children inside the scroll area.
204    pub fn gap(mut self, gap: impl Into<Pixels>) -> Self {
205        self.gap = gap.into();
206        self
207    }
208
209    /// The one slot for caller-owned low-level styling: GPUI's styling methods
210    /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
211    /// applied to the scroll shadow's root element after every value the
212    /// orientation, the size and the active theme chose, so they win.
213    pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
214        crate::util::refine_sx(&mut self.sx, style);
215        self
216    }
217}
218
219impl ParentElement for ScrollShadow {
220    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
221        self.children.extend(elements);
222    }
223}
224
225impl RenderOnce for ScrollShadow {
226    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
227        // Where the content sits, as of the last frame: `use_keyed_state` takes
228        // `cx` mutably, so both slots precede the theme borrow.
229        let scroll = self.external_scroll.clone().unwrap_or_else(|| {
230            window
231                .use_keyed_state(element_id::scoped(&self.id, "scroll"), cx, |_, _| {
232                    ScrollHandle::new()
233                })
234                .read(cx)
235                .clone()
236        });
237        let reported = window.use_keyed_state(
238            element_id::scoped(&self.id, "shadow-visibility"),
239            cx,
240            |_, _| ScrollShadowVisibility::None,
241        );
242        let bg = cx.colors().background;
243        let horizontal = self.orientation.is_horizontal();
244
245        let mut scroller = div()
246            .id(self.id.clone())
247            .track_scroll(&scroll)
248            .restrict_scroll_to_axis()
249            .overflow_hidden()
250            .flex()
251            .gap(self.gap);
252
253        scroller = if horizontal {
254            scroller.flex_row().overflow_x_scroll()
255        } else {
256            scroller.flex_col().overflow_y_scroll()
257        };
258
259        // A mouse wheel reports only `deltaY`, and the web platform forwards
260        // the raw axes, so shift+wheel never reaches a horizontal scroller
261        // there (native platforms translate it themselves). Until that lands
262        // upstream, drive the handle's x offset from the vertical delta here;
263        // see `util::shift_wheel_scroll_x`. `restrict_scroll_to_axis` already
264        // drops the vertical component from `track_scroll`, so the two do not
265        // double-scroll. Native builds keep flowing through `track_scroll`
266        // untouched: the helper is a web-only no-op elsewhere.
267        if horizontal {
268            let wheel_scroll = scroll.clone();
269            scroller = scroller.on_scroll_wheel(move |event, _window, _cx| {
270                crate::util::shift_wheel_scroll_x(&wheel_scroll, event);
271            });
272        }
273
274        if self.fill_axis {
275            scroller = if horizontal {
276                scroller.w_full().min_w(px(0.))
277            } else {
278                scroller.h_full().min_h(px(0.))
279            };
280        }
281
282        if let Some(h) = self.max_h {
283            scroller = scroller.max_h(h);
284        }
285        if let Some(w) = self.max_w {
286            scroller = scroller.max_w(w);
287        }
288
289        scroller = scroller.children(self.children);
290
291        let bar = (!self.hide_scroll_bar).then(|| {
292            crate::scrollbar::Scrollbar::new(
293                element_id::scoped(&self.id, "scrollbar"),
294                scroll.clone(),
295            )
296            .orientation(self.orientation)
297        });
298
299        if !self.is_enabled || self.visibility == ScrollShadowVisibility::None {
300            let mut root = div().relative().child(scroller);
301            if let Some(bar) = bar {
302                root = root.child(bar);
303            }
304            return crate::util::apply_sx(root, &self.sx).into_any_element();
305        }
306
307        // `Auto`: the leading fade once the content has been scrolled away from
308        // the start, the trailing one until the end is reached. `offset` counts
309        // *backwards* from zero, and `max_offset` is how far it can go.
310        //
311        // The scroller's wheel listener adds the delta straight into the
312        // tracked handle's offset cell during event dispatch; layout clamps it
313        // into `[-max, 0]` only on the next pass. A wheel over a box whose
314        // content fits (`max` = 0) would therefore read as a one-frame -40px
315        // offset here and resolve `Auto` to a spurious one-frame edge shadow.
316        // Clamp what this render reads to the same range layout clamps to:
317        // `Auto` must never report an edge while the offset sits outside the
318        // scrollable range, which is exactly the "nothing to scroll" case —
319        // v3's contract is that content which fits shows no shadow, ever.
320        let offset = scroll.offset();
321        let max = scroll.max_offset();
322        let (scrolled, scroll_max) = if horizontal {
323            (
324                f32::from(offset.x).clamp(-f32::from(max.x), 0.0),
325                f32::from(max.x),
326            )
327        } else {
328            (
329                f32::from(offset.y).clamp(-f32::from(max.y), 0.0),
330                f32::from(max.y),
331            )
332        };
333        let (past_start, before_end) = (
334            scrolled < -f32::from(self.offset),
335            scrolled - f32::from(self.offset) > -scroll_max,
336        );
337        let resolved = if self.visibility == ScrollShadowVisibility::Auto {
338            match (past_start, before_end) {
339                (true, true) => ScrollShadowVisibility::Both,
340                (true, false) if horizontal => ScrollShadowVisibility::Left,
341                (true, false) => ScrollShadowVisibility::Top,
342                (false, true) if horizontal => ScrollShadowVisibility::Right,
343                (false, true) => ScrollShadowVisibility::Bottom,
344                (false, false) => ScrollShadowVisibility::None,
345            }
346        } else {
347            self.visibility
348        };
349        let reports_visibility = self.visibility == ScrollShadowVisibility::Auto;
350
351        // The fades are absolutely positioned siblings so they do not scroll
352        // with the content.
353        // Fade to the *same* colour at zero alpha. Interpolating toward
354        // `transparent_black` would drag the midpoint through grey.
355        let clear = bg.alpha(0.0);
356        let fade = |from_start: bool| {
357            let stops = if from_start { (bg, clear) } else { (clear, bg) };
358            let angle = if horizontal { 90.0 } else { 180.0 };
359            let mut el = div().absolute().bg(gpui::linear_gradient(
360                angle,
361                gpui::linear_color_stop(stops.0, 0.0),
362                gpui::linear_color_stop(stops.1, 1.0),
363            ));
364            el = if horizontal {
365                el.top_0().bottom_0().w(self.size)
366            } else {
367                el.left_0().right_0().h(self.size)
368            };
369            match (horizontal, from_start) {
370                (true, true) => el.left(self.offset),
371                (true, false) => el.right(self.offset),
372                (false, true) => el.top(self.offset),
373                (false, false) => el.bottom(self.offset),
374            }
375        };
376
377        let root = div()
378            .relative()
379            .child(scroller)
380            .when(resolved.shows_start(self.orientation), |el| {
381                el.child(fade(true))
382            })
383            .when(resolved.shows_end(self.orientation), |el| {
384                el.child(fade(false))
385            })
386            .when_some(bar, |el, bar| el.child(bar))
387            .when(reports_visibility, |el| {
388                // The offset is written during prepaint, so what changed is
389                // known here and reported from here.
390                let handler = self.on_visibility_change.clone();
391                el.child(
392                    canvas(
393                        move |_, window, cx| {
394                            if *reported.read(cx) != resolved {
395                                reported.update(cx, |value, cx| {
396                                    *value = resolved;
397                                    cx.notify();
398                                });
399                                if let Some(f) = &handler {
400                                    f(&resolved, window, cx);
401                                }
402                            }
403                        },
404                        |_, _, _, _| {},
405                    )
406                    .absolute()
407                    .size(px(0.)),
408                )
409            });
410        crate::util::apply_sx(root, &self.sx).into_any_element()
411    }
412}
413
414crate::util::impl_component_styled!(ScrollShadow);