Skip to main content

ccf_gpui_widgets/widgets/
scrollable.rs

1//! Scrollable component with visible scrollbars.
2//!
3//! A wrapper component that adds visible, interactive scrollbars to any content.
4//! Unlike native GPUI `overflow_y_scroll()` which enables scrolling but renders no
5//! visible scrollbar, this component provides:
6//!
7//! - Visible, themed scrollbars
8//! - `.always_show_scrollbars()` option
9//! - Interactive thumb (drag to scroll)
10//! - Click-on-track to jump
11//! - Auto-fade after inactivity
12//! - ScrollHandle integration for programmatic control
13//! - Scroll events don't bubble to parent containers
14//!
15//! # Vertical Scrolling
16//!
17//! Vertical scrolling works naturally with GPUI's layout system. Content that
18//! exceeds the container height will automatically trigger scrolling.
19//!
20//! ```ignore
21//! use ccf_gpui_widgets::scrollable_vertical;
22//! use gpui::*;
23//!
24//! // Container with fixed height - content scrolls when it exceeds this height
25//! div()
26//!     .h(px(200.0))
27//!     .child(
28//!         scrollable_vertical(
29//!             div().children(many_items)  // Content grows naturally
30//!         )
31//!     )
32//! ```
33//!
34//! **Vertical scrolling pitfalls:**
35//! - The scrollable container needs a constrained height (explicit or from parent)
36//! - Without height constraint, content expands infinitely and never scrolls
37//! - Use `.h(px(...))`, `.max_h(px(...))`, or ensure parent constrains height
38//!
39//! # Horizontal Scrolling
40//!
41//! **Important:** Horizontal scrolling requires explicit width on content due to
42//! GPUI layout limitations. Flex items shrink to fit by default, so without
43//! explicit width, GPUI cannot detect content overflow.
44//!
45//! ```ignore
46//! use ccf_gpui_widgets::scrollable_horizontal;
47//! use gpui::*;
48//!
49//! // Container with fixed width
50//! div()
51//!     .w(px(300.0))
52//!     .child(
53//!         scrollable_horizontal(
54//!             div()
55//!                 .w(px(800.0))  // REQUIRED: explicit width > container width
56//!                 .flex()
57//!                 .flex_row()
58//!                 .gap_2()
59//!                 .children(items)
60//!         )
61//!     )
62//! ```
63//!
64//! **Horizontal scrolling pitfalls:**
65//! - Content MUST have explicit width via `.w(px(...))` that exceeds container
66//! - `flex_shrink_0()` on items is NOT sufficient - the container needs explicit width
67//! - Without explicit width, scrollbar won't appear and content won't scroll
68//! - Calculate required width based on content (e.g., `num_items * item_width + gaps`)
69//!
70//! **What does NOT work for horizontal:**
71//! ```ignore
72//! // WRONG: No explicit width - content shrinks to fit container
73//! scrollable_horizontal(
74//!     div()
75//!         .flex()
76//!         .flex_row()
77//!         .children(items.iter().map(|i| div().flex_shrink_0().child(i)))
78//! )
79//!
80//! // WRONG: flex_shrink_0 on container doesn't help
81//! scrollable_horizontal(
82//!     div()
83//!         .flex_shrink_0()  // This doesn't prevent layout constraint
84//!         .flex()
85//!         .flex_row()
86//!         .children(items)
87//! )
88//! ```
89//!
90//! # Bidirectional Scrolling
91//!
92//! For content that scrolls both horizontally and vertically:
93//!
94//! ```ignore
95//! use ccf_gpui_widgets::scrollable_both;
96//!
97//! scrollable_both(
98//!     div()
99//!         .w(px(800.0))  // Explicit width for horizontal
100//!         // Height grows naturally for vertical
101//!         .children(content)
102//! )
103//! ```
104//!
105//! # Options
106//!
107//! ```ignore
108//! scrollable_vertical(content)
109//!     .with_scroll_handle(my_handle)  // For programmatic scroll control
110//!     .always_show_scrollbars()       // Don't auto-hide scrollbars
111//!     .theme(custom_theme)            // Custom scrollbar colors
112//!     .id("my-scrollable")            // Custom element ID
113//! ```
114
115use super::scrollbar::{Scrollbar, ScrollbarAxis, ScrollbarState};
116use crate::theme::Theme;
117use gpui::{
118    div, relative, AnyElement, App, Bounds, Div, Element, ElementId, GlobalElementId,
119    InspectorElementId, InteractiveElement, Interactivity, IntoElement, LayoutId, ParentElement,
120    Pixels, Position, ScrollHandle, SharedString, Stateful, StatefulInteractiveElement, Style,
121    StyleRefinement, Styled, Window,
122};
123use std::panic::Location;
124
125/// A scroll view with visible scrollbars
126///
127/// Wraps content and adds themed scrollbars that appear on scroll
128/// and fade out after inactivity.
129pub struct Scrollable<E> {
130    id: ElementId,
131    element: Option<E>,
132    axis: ScrollbarAxis,
133    always_show_scrollbars: bool,
134    external_scroll_handle: Option<ScrollHandle>,
135    custom_theme: Option<Theme>,
136    _element: Stateful<Div>,
137}
138
139impl<E> Scrollable<E>
140where
141    E: Element,
142{
143    /// Internal constructor that uses the provided location for ID generation
144    fn new_with_location(axis: ScrollbarAxis, element: E, location: &'static Location<'static>) -> Self {
145        // Generate a stable ID based on call site location
146        // This ensures the same scrollable gets the same ID across renders
147        let id = ElementId::Name(SharedString::from(format!(
148            "scrollable-{}:{}:{}",
149            location.file(),
150            location.line(),
151            location.column()
152        )));
153
154        Self {
155            element: Some(element),
156            _element: div().id("fake"),
157            id,
158            axis,
159            always_show_scrollbars: false,
160            external_scroll_handle: None,
161            custom_theme: None,
162        }
163    }
164
165    /// Create a vertical scrollable container
166    #[track_caller]
167    pub fn vertical(element: E) -> Self {
168        Self::new_with_location(ScrollbarAxis::Vertical, element, Location::caller())
169    }
170
171    /// Create a horizontal scrollable container
172    #[track_caller]
173    pub fn horizontal(element: E) -> Self {
174        Self::new_with_location(ScrollbarAxis::Horizontal, element, Location::caller())
175    }
176
177    /// Create a scrollable container with both axes
178    #[track_caller]
179    pub fn both(element: E) -> Self {
180        Self::new_with_location(ScrollbarAxis::Both, element, Location::caller())
181    }
182
183    /// Always show scrollbars (don't fade out)
184    #[must_use]
185    pub fn always_show_scrollbars(mut self) -> Self {
186        self.always_show_scrollbars = true;
187        self
188    }
189
190    /// Attach an external scroll handle for programmatic control
191    #[must_use]
192    pub fn with_scroll_handle(mut self, handle: ScrollHandle) -> Self {
193        self.external_scroll_handle = Some(handle);
194        self
195    }
196
197    /// Set the element ID
198    #[must_use]
199    pub fn id(mut self, id: impl Into<ElementId>) -> Self {
200        self.id = id.into();
201        self
202    }
203
204    /// Set custom theme (builder pattern)
205    #[must_use]
206    pub fn theme(mut self, theme: Theme) -> Self {
207        self.custom_theme = Some(theme);
208        self
209    }
210
211    fn with_element_state<R>(
212        &mut self,
213        id: &GlobalElementId,
214        window: &mut Window,
215        cx: &mut App,
216        f: impl FnOnce(&mut Self, &mut ScrollViewState, &mut Window, &mut App) -> R,
217    ) -> R {
218        window.with_optional_element_state::<ScrollViewState, _>(Some(id), |element_state, window| {
219            let mut element_state = element_state.unwrap().unwrap_or_default();
220            let result = f(self, &mut element_state, window, cx);
221            (result, Some(element_state))
222        })
223    }
224}
225
226/// Internal state for the scroll view
227pub struct ScrollViewState {
228    state: ScrollbarState,
229    handle: ScrollHandle,
230}
231
232impl Default for ScrollViewState {
233    fn default() -> Self {
234        Self {
235            handle: ScrollHandle::new(),
236            state: ScrollbarState::default(),
237        }
238    }
239}
240
241impl<E> ParentElement for Scrollable<E>
242where
243    E: Element + ParentElement,
244{
245    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
246        if let Some(element) = &mut self.element {
247            element.extend(elements);
248        }
249    }
250}
251
252impl<E> Styled for Scrollable<E>
253where
254    E: Element + Styled,
255{
256    fn style(&mut self) -> &mut StyleRefinement {
257        if let Some(element) = &mut self.element {
258            element.style()
259        } else {
260            self._element.style()
261        }
262    }
263}
264
265impl<E> InteractiveElement for Scrollable<E>
266where
267    E: Element + InteractiveElement,
268{
269    fn interactivity(&mut self) -> &mut Interactivity {
270        if let Some(element) = &mut self.element {
271            element.interactivity()
272        } else {
273            self._element.interactivity()
274        }
275    }
276}
277
278impl<E> StatefulInteractiveElement for Scrollable<E> where E: Element + StatefulInteractiveElement {}
279
280impl<E> IntoElement for Scrollable<E>
281where
282    E: Element,
283{
284    type Element = Self;
285
286    fn into_element(self) -> Self::Element {
287        self
288    }
289}
290
291impl<E> Element for Scrollable<E>
292where
293    E: Element,
294{
295    type RequestLayoutState = AnyElement;
296    type PrepaintState = ScrollViewState;
297
298    fn id(&self) -> Option<ElementId> {
299        Some(self.id.clone())
300    }
301
302    fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
303        None
304    }
305
306    fn request_layout(
307        &mut self,
308        id: Option<&GlobalElementId>,
309        _: Option<&InspectorElementId>,
310        window: &mut Window,
311        cx: &mut App,
312    ) -> (LayoutId, Self::RequestLayoutState) {
313        let mut style = Style {
314            flex_grow: 1.0,
315            position: Position::Relative,
316            ..Default::default()
317        };
318        style.size.width = relative(1.0).into();
319        style.size.height = relative(1.0).into();
320
321        let axis = self.axis;
322        let scroll_id = self.id.clone();
323        let content = self.element.take().map(|c| c.into_any_element());
324        let always_show = self.always_show_scrollbars;
325
326        self.with_element_state(
327            id.unwrap(),
328            window,
329            cx,
330            |scrollable, element_state, window, cx| {
331                let scroll_handle = if let Some(ref external_handle) =
332                    scrollable.external_scroll_handle
333                {
334                    external_handle
335                } else {
336                    &element_state.handle
337                };
338
339                let mut scrollbar = Scrollbar::new(axis, &element_state.state, scroll_handle);
340                if always_show {
341                    scrollbar = scrollbar.always_visible();
342                }
343                if let Some(ref theme) = scrollable.custom_theme {
344                    scrollbar = scrollbar.theme(*theme);
345                }
346
347                // Build the scroll container with axis-appropriate layout
348                let inner_scroll = div()
349                    .id(scroll_id.clone())
350                    .track_scroll(scroll_handle)
351                    .on_scroll_wheel(|_event, _window, cx| {
352                        // Stop propagation to prevent parent containers from scrolling
353                        cx.stop_propagation();
354                    });
355
356                // Apply axis-specific layout and overflow
357                let inner_scroll = match axis {
358                    ScrollbarAxis::Vertical => {
359                        // Vertical: wrap content to allow height growth
360                        inner_scroll
361                            .size_full()
362                            .overflow_y_scroll()
363                            .child(div().w_full().children(content))
364                    }
365                    ScrollbarAxis::Horizontal => {
366                        // Horizontal: no wrapper, content directly in scroll container
367                        inner_scroll
368                            .size_full()
369                            .overflow_x_scroll()
370                            .children(content)
371                    }
372                    ScrollbarAxis::Both => {
373                        // Both: wrap content to allow growth in both directions
374                        inner_scroll
375                            .size_full()
376                            .overflow_scroll()
377                            .child(div().flex_shrink_0().children(content))
378                    }
379                };
380
381                let mut element = div()
382                    .relative()
383                    .size_full()
384                    .overflow_hidden()
385                    .child(inner_scroll)
386                    .child(
387                        div()
388                            .absolute()
389                            .top_0()
390                            .left_0()
391                            .right_0()
392                            .bottom_0()
393                            .child(scrollbar),
394                    )
395                    .into_any_element();
396
397                let element_id = element.request_layout(window, cx);
398                let layout_id = window.request_layout(style, vec![element_id], cx);
399
400                (layout_id, element)
401            },
402        )
403    }
404
405    fn prepaint(
406        &mut self,
407        id: Option<&GlobalElementId>,
408        _: Option<&InspectorElementId>,
409        _: Bounds<Pixels>,
410        element: &mut Self::RequestLayoutState,
411        window: &mut Window,
412        cx: &mut App,
413    ) -> Self::PrepaintState {
414        element.prepaint(window, cx);
415
416        // Access the cached state to preserve scroll position
417        self.with_element_state(id.unwrap(), window, cx, |_, state, _, _| ScrollViewState {
418            handle: state.handle.clone(),
419            state: state.state.clone(),
420        })
421    }
422
423    fn paint(
424        &mut self,
425        _: Option<&GlobalElementId>,
426        _: Option<&InspectorElementId>,
427        _: Bounds<Pixels>,
428        element: &mut Self::RequestLayoutState,
429        _: &mut Self::PrepaintState,
430        window: &mut Window,
431        cx: &mut App,
432    ) {
433        element.paint(window, cx)
434    }
435}
436
437/// Create a vertical scrollable container
438///
439/// # Example
440///
441/// ```ignore
442/// scrollable_vertical(
443///     div()
444///         .flex()
445///         .flex_col()
446///         .children(items)
447/// )
448/// ```
449#[track_caller]
450pub fn scrollable_vertical<E>(element: E) -> Scrollable<E>
451where
452    E: Element,
453{
454    Scrollable::new_with_location(ScrollbarAxis::Vertical, element, Location::caller())
455}
456
457/// Create a horizontal scrollable container
458///
459/// # Example
460///
461/// ```ignore
462/// scrollable_horizontal(
463///     div()
464///         .flex()
465///         .flex_row()
466///         .children(items)
467/// )
468/// ```
469#[track_caller]
470pub fn scrollable_horizontal<E>(element: E) -> Scrollable<E>
471where
472    E: Element,
473{
474    Scrollable::new_with_location(ScrollbarAxis::Horizontal, element, Location::caller())
475}
476
477/// Create a scrollable container with both axes
478///
479/// # Example
480///
481/// ```ignore
482/// scrollable_both(
483///     div().children(items)
484/// )
485/// ```
486#[track_caller]
487pub fn scrollable_both<E>(element: E) -> Scrollable<E>
488where
489    E: Element,
490{
491    Scrollable::new_with_location(ScrollbarAxis::Both, element, Location::caller())
492}