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}