tessera_ui_basic_components/
scrollable.rs

1//! Scrollable container component for Tessera UI.
2//!
3//! This module provides a scrollable container that enables vertical and/or horizontal scrolling
4//! for overflowing content within a UI layout. It is designed as a fundamental building block
5//! for creating areas where content may exceed the visible bounds, such as lists, panels, or
6//! custom scroll regions.
7//!
8//! Features include configurable scroll directions, smooth animated scrolling, and stateful
9//! management of scroll position and focus. The scrollable area is highly customizable via
10//! [`ScrollableArgs`], and integrates with the Tessera UI state management system.
11//!
12//! Typical use cases include scrollable lists, text areas, image galleries, or any UI region
13//! where content may not fit within the allocated space.
14//!
15//! # Example
16//! See [`scrollable()`] for usage details and code samples.
17mod scrollbar;
18
19use std::{sync::Arc, time::Instant};
20
21use derive_builder::Builder;
22use parking_lot::RwLock;
23use tessera_ui::{
24    Color, ComputedData, Constraint, CursorEventContent, DimensionValue, Dp, Px, PxPosition,
25};
26use tessera_ui_macros::tessera;
27
28use crate::{
29    alignment::Alignment,
30    boxed::BoxedArgsBuilder,
31    boxed_ui,
32    pos_misc::is_position_in_component,
33    scrollable::scrollbar::{ScrollBarArgs, ScrollBarState, scrollbar_h, scrollbar_v},
34};
35
36#[derive(Debug, Builder, Clone)]
37pub struct ScrollableArgs {
38    /// The desired width behavior of the scrollable area
39    /// Defaults to Wrap { min: None, max: None }.
40    #[builder(default = "tessera_ui::DimensionValue::Wrap { min: None, max: None }")]
41    pub width: tessera_ui::DimensionValue,
42    /// The desired height behavior of the scrollable area.
43    /// Defaults to Wrap { min: None, max: None }.
44    #[builder(default = "tessera_ui::DimensionValue::Wrap { min: None, max: None }")]
45    pub height: tessera_ui::DimensionValue,
46    /// Is vertical scrollable?
47    /// Defaults to `true` since most scrollable areas are vertical.
48    #[builder(default = "true")]
49    pub vertical: bool,
50    /// Is horizontal scrollable?
51    /// Defaults to `false` since most scrollable areas are not horizontal.
52    #[builder(default = "false")]
53    pub horizontal: bool,
54    /// Scroll smoothing factor (0.0 = instant, 1.0 = very smooth).
55    /// Defaults to 0.05 for very responsive but still smooth scrolling.
56    #[builder(default = "0.05")]
57    pub scroll_smoothing: f32,
58    /// The behavior of the scrollbar visibility.
59    #[builder(default = "ScrollBarBehavior::AlwaysVisible")]
60    pub scrollbar_behavior: ScrollBarBehavior,
61    /// The color of the scrollbar track.
62    #[builder(default = "Color::new(0.0, 0.0, 0.0, 0.1)")]
63    pub scrollbar_track_color: Color,
64    /// The color of the scrollbar thumb.
65    #[builder(default = "Color::new(0.0, 0.0, 0.0, 0.3)")]
66    pub scrollbar_thumb_color: Color,
67    /// The color of the scrollbar thumb when hovered.
68    #[builder(default = "Color::new(0.0, 0.0, 0.0, 0.5)")]
69    pub scrollbar_thumb_hover_color: Color,
70    /// The layout of the scrollbar relative to the content.
71    #[builder(default = "ScrollBarLayout::Alongside")]
72    pub scrollbar_layout: ScrollBarLayout,
73}
74
75/// Defines the behavior of the scrollbar visibility.
76#[derive(Debug, Clone)]
77pub enum ScrollBarBehavior {
78    /// The scrollbar is always visible.
79    AlwaysVisible,
80    /// The scrollbar is only visible when scrolling.
81    AutoHide,
82    /// No scrollbar at all.
83    Hidden,
84}
85
86/// Defines the layout of the scrollbar relative to the scrollable content.
87#[derive(Debug, Clone)]
88pub enum ScrollBarLayout {
89    /// The scrollbar is placed alongside the content (takes up space in the layout).
90    Alongside,
91    /// The scrollbar is overlaid on top of the content (doesn't take up space).
92    Overlay,
93}
94
95impl Default for ScrollableArgs {
96    fn default() -> Self {
97        ScrollableArgsBuilder::default().build().unwrap()
98    }
99}
100
101/// Holds the state for a `scrollable` component, managing scroll position and interaction.
102///
103/// This state should be created and managed using `use_state` or a similar state management
104/// hook provided by the UI framework. It tracks the current and target scroll positions,
105/// the size of the scrollable content, and focus state.
106///
107/// The scroll position is smoothly interpolated over time to create a fluid scrolling effect.
108#[derive(Default)]
109pub struct ScrollableState {
110    /// The inner state containing scroll position, size
111    inner: Arc<RwLock<ScrollableStateInner>>,
112    /// The state for vertical scrollbar
113    scrollbar_state_v: Arc<RwLock<ScrollBarState>>,
114    /// The state for horizontal scrollbar
115    scrollbar_state_h: Arc<RwLock<ScrollBarState>>,
116}
117
118impl ScrollableState {
119    /// Creates a new `ScrollableState` with default values.
120    pub fn new() -> Self {
121        Self::default()
122    }
123}
124
125#[derive(Clone, Debug)]
126struct ScrollableStateInner {
127    /// The current position of the child component (for rendering)
128    child_position: PxPosition,
129    /// The target position of the child component (scrolling destination)
130    target_position: PxPosition,
131    /// The child component size
132    child_size: ComputedData,
133    /// The visible area size
134    visible_size: ComputedData,
135    /// Last frame time for delta time calculation
136    last_frame_time: Option<Instant>,
137}
138
139impl Default for ScrollableStateInner {
140    fn default() -> Self {
141        Self::new()
142    }
143}
144
145impl ScrollableStateInner {
146    /// Creates a new ScrollableState with default values.
147    pub fn new() -> Self {
148        Self {
149            child_position: PxPosition::ZERO,
150            target_position: PxPosition::ZERO,
151            child_size: ComputedData::ZERO,
152            visible_size: ComputedData::ZERO,
153            last_frame_time: None,
154        }
155    }
156
157    /// Updates the scroll position based on time-based interpolation
158    /// Returns true if the position changed (needs redraw)
159    fn update_scroll_position(&mut self, smoothing: f32) -> bool {
160        let current_time = Instant::now();
161
162        // Calculate delta time
163        let delta_time = if let Some(last_time) = self.last_frame_time {
164            current_time.duration_since(last_time).as_secs_f32()
165        } else {
166            0.016 // Assume 60fps for first frame
167        };
168
169        self.last_frame_time = Some(current_time);
170
171        // Calculate the difference between target and current position
172        let diff_x = self.target_position.x.to_f32() - self.child_position.x.to_f32();
173        let diff_y = self.target_position.y.to_f32() - self.child_position.y.to_f32();
174
175        // If we're close enough to target, snap to it
176        if diff_x.abs() < 1.0 && diff_y.abs() < 1.0 {
177            if self.child_position != self.target_position {
178                self.child_position = self.target_position;
179                return true;
180            }
181            return false;
182        }
183
184        // Use simple velocity-based movement for consistent behavior
185        // Higher smoothing = slower movement
186        let mut movement_factor = (1.0 - smoothing) * delta_time * 60.0;
187
188        // CRITICAL FIX: Clamp the movement factor to a maximum of 1.0.
189        // A factor greater than 1.0 causes the interpolation to overshoot the target,
190        // leading to oscillations that grow exponentially, causing the value explosion
191        // and overflow panic seen in the logs. Clamping ensures stability by
192        // preventing the position from moving past the target in a single frame.
193        if movement_factor > 1.0 {
194            movement_factor = 1.0;
195        }
196        let old_position = self.child_position;
197
198        self.child_position = PxPosition {
199            x: Px::saturating_from_f32(self.child_position.x.to_f32() + diff_x * movement_factor),
200            y: Px::saturating_from_f32(self.child_position.y.to_f32() + diff_y * movement_factor),
201        };
202
203        // Return true if position changed significantly
204        old_position != self.child_position
205    }
206
207    /// Sets a new target position for scrolling
208    fn set_target_position(&mut self, target: PxPosition) {
209        self.target_position = target;
210    }
211}
212
213/// A container that makes its content scrollable when it exceeds the container's size.
214///
215/// The `scrollable` component is a fundamental building block for creating areas with
216/// content that may not fit within the allocated space. It supports vertical and/or
217/// horizontal scrolling, which can be configured via `ScrollableArgs`.
218///
219/// The component offers two scrollbar layout options:
220/// - `Alongside`: Scrollbars take up space in the layout alongside the content
221/// - `Overlay`: Scrollbars are overlaid on top of the content without taking up space
222///
223/// State management is handled by `ScrollableState`, which must be provided to persist
224/// the scroll position across recompositions. The scrolling behavior is animated with
225/// a configurable smoothing factor for a better user experience.
226///
227/// # Example
228///
229/// ```
230/// use std::sync::Arc;
231/// use parking_lot::RwLock;
232/// use tessera_ui::{DimensionValue, Dp};
233/// use tessera_ui_basic_components::{
234///     column::{column_ui, ColumnArgs},
235///     scrollable::{scrollable, ScrollableArgs, ScrollableState, ScrollBarLayout},
236///     text::text,
237/// };
238///
239/// // In a real app, you would manage the state.
240/// let scrollable_state = Arc::new(ScrollableState::new());
241///
242/// // Example with alongside scrollbars (default)
243/// scrollable(
244///     ScrollableArgs {
245///         height: DimensionValue::Fixed(Dp(100.0).into()),
246///         scrollbar_layout: ScrollBarLayout::Alongside,
247///         ..Default::default()
248///     },
249///     scrollable_state.clone(),
250///     || {
251///         column_ui!(
252///             ColumnArgs::default(),
253///             || text("Item 1".to_string()),
254///             || text("Item 2".to_string()),
255///             || text("Item 3".to_string()),
256///             || text("Item 4".to_string()),
257///             || text("Item 5".to_string()),
258///             || text("Item 6".to_string()),
259///             || text("Item 7".to_string()),
260///             || text("Item 8".to_string()),
261///             || text("Item 9".to_string()),
262///             || text("Item 10".to_string()),
263///         );
264///     },
265/// );
266///
267/// // Example with overlay scrollbars
268/// scrollable(
269///     ScrollableArgs {
270///         height: DimensionValue::Fixed(Dp(100.0).into()),
271///         scrollbar_layout: ScrollBarLayout::Overlay,
272///         ..Default::default()
273///     },
274///     scrollable_state,
275///     || {
276///         column_ui!(
277///             ColumnArgs::default(),
278///             || text("Item 1".to_string()),
279///             || text("Item 2".to_string()),
280///             || text("Item 3".to_string()),
281///             || text("Item 4".to_string()),
282///             || text("Item 5".to_string()),
283///             || text("Item 6".to_string()),
284///             || text("Item 7".to_string()),
285///             || text("Item 8".to_string()),
286///             || text("Item 9".to_string()),
287///             || text("Item 10".to_string()),
288///         );
289///     },
290/// );
291/// ```
292///
293/// # Panics
294///
295/// This component will panic if it does not have exactly one child.
296///
297/// # Arguments
298///
299/// * `args`: An instance of `ScrollableArgs` or `ScrollableArgsBuilder` to configure the
300///   scrollable area's behavior, such as dimensions and scroll directions.
301/// * `state`: An `Arc<RwLock<ScrollableState>>` to hold and manage the component's state.
302/// * `child`: A closure that defines the content to be placed inside the scrollable container.
303///   This closure is executed once to build the component tree.
304#[tessera]
305pub fn scrollable(
306    args: impl Into<ScrollableArgs>,
307    state: Arc<ScrollableState>,
308    child: impl FnOnce() + Send + Sync + 'static,
309) {
310    let args: ScrollableArgs = args.into();
311
312    // Create separate ScrollBarArgs for vertical and horizontal scrollbars
313    let scrollbar_args_v = ScrollBarArgs {
314        total: state.inner.read().child_size.height,
315        visible: state.inner.read().visible_size.height,
316        offset: state.inner.read().child_position.y,
317        thickness: Dp(8.0), // Default scrollbar thickness
318        state: state.inner.clone(),
319        scrollbar_behavior: args.scrollbar_behavior.clone(),
320        track_color: args.scrollbar_track_color,
321        thumb_color: args.scrollbar_thumb_color,
322        thumb_hover_color: args.scrollbar_thumb_hover_color,
323    };
324
325    let scrollbar_args_h = ScrollBarArgs {
326        total: state.inner.read().child_size.width,
327        visible: state.inner.read().visible_size.width,
328        offset: state.inner.read().child_position.x,
329        thickness: Dp(8.0), // Default scrollbar thickness
330        state: state.inner.clone(),
331        scrollbar_behavior: args.scrollbar_behavior.clone(),
332        track_color: args.scrollbar_track_color,
333        thumb_color: args.scrollbar_thumb_color,
334        thumb_hover_color: args.scrollbar_thumb_hover_color,
335    };
336
337    match args.scrollbar_layout {
338        ScrollBarLayout::Alongside => {
339            scrollable_with_alongside_scrollbar(
340                state,
341                args,
342                scrollbar_args_v,
343                scrollbar_args_h,
344                child,
345            );
346        }
347        ScrollBarLayout::Overlay => {
348            scrollable_with_overlay_scrollbar(
349                state,
350                args,
351                scrollbar_args_v,
352                scrollbar_args_h,
353                child,
354            );
355        }
356    }
357}
358
359#[tessera]
360fn scrollable_with_alongside_scrollbar(
361    state: Arc<ScrollableState>,
362    args: ScrollableArgs,
363    scrollbar_args_v: ScrollBarArgs,
364    scrollbar_args_h: ScrollBarArgs,
365    child: impl FnOnce() + Send + Sync + 'static,
366) {
367    scrollable_inner(
368        args.clone(),
369        state.inner.clone(),
370        state.scrollbar_state_v.clone(),
371        state.scrollbar_state_h.clone(),
372        child,
373    );
374
375    if args.vertical {
376        scrollbar_v(scrollbar_args_v, state.scrollbar_state_v.clone());
377    }
378
379    if args.horizontal {
380        scrollbar_h(scrollbar_args_h, state.scrollbar_state_h.clone());
381    }
382
383    measure(Box::new(move |input| {
384        // Record the final size
385        let mut final_size = ComputedData::ZERO;
386        // Get parent constraint as content constraint
387        let mut content_contraint = input.parent_constraint.to_owned();
388        // measure the scrollbar
389        if args.vertical {
390            let scrollbar_node_id = input.children_ids[1];
391            let size = input.measure_child(scrollbar_node_id, input.parent_constraint)?;
392            // substract the scrollbar size from the content constraint
393            content_contraint.width -= size.width;
394            // update the size
395            final_size.width += size.width;
396        }
397        if args.horizontal {
398            let scrollbar_node_id = if args.vertical {
399                input.children_ids[2]
400            } else {
401                input.children_ids[1]
402            };
403            let size = input.measure_child(scrollbar_node_id, input.parent_constraint)?;
404            content_contraint.height -= size.height;
405            // update the size
406            final_size.height += size.height;
407        }
408        // Measure the content
409        let content_node_id = input.children_ids[0];
410        let content_measurement = input.measure_child(content_node_id, &content_contraint)?;
411        // update the size
412        final_size.width += content_measurement.width;
413        final_size.height += content_measurement.height;
414        // Place childrens
415        // place the content at [0, 0]
416        input.place_child(content_node_id, PxPosition::ZERO);
417        // place the scrollbar at the end
418        if args.vertical {
419            input.place_child(
420                input.children_ids[1],
421                PxPosition::new(content_measurement.width, Px::ZERO),
422            );
423        }
424        if args.horizontal {
425            let scrollbar_node_id = if args.vertical {
426                input.children_ids[2]
427            } else {
428                input.children_ids[1]
429            };
430            input.place_child(
431                scrollbar_node_id,
432                PxPosition::new(Px::ZERO, content_measurement.height),
433            );
434        }
435        // Return the computed data
436        Ok(final_size)
437    }));
438}
439
440#[tessera]
441fn scrollable_with_overlay_scrollbar(
442    state: Arc<ScrollableState>,
443    args: ScrollableArgs,
444    scrollbar_args_v: ScrollBarArgs,
445    scrollbar_args_h: ScrollBarArgs,
446    child: impl FnOnce() + Send + Sync + 'static,
447) {
448    boxed_ui!(
449        BoxedArgsBuilder::default()
450            .width(args.width)
451            .height(args.height)
452            .alignment(Alignment::BottomEnd)
453            .build()
454            .unwrap(),
455        {
456            let state = state.clone();
457            let args = args.clone();
458            move || {
459                scrollable_inner(
460                    args,
461                    state.inner.clone(),
462                    state.scrollbar_state_v.clone(),
463                    state.scrollbar_state_h.clone(),
464                    child,
465                );
466            }
467        },
468        {
469            let scrollbar_args_v = scrollbar_args_v.clone();
470            let args = args.clone();
471            let state = state.clone();
472            move || {
473                if args.vertical {
474                    scrollbar_v(scrollbar_args_v, state.scrollbar_state_v.clone());
475                }
476            }
477        },
478        {
479            let scrollbar_args_h = scrollbar_args_h.clone();
480            let args = args.clone();
481            let state = state.clone();
482            move || {
483                if args.horizontal {
484                    scrollbar_h(scrollbar_args_h, state.scrollbar_state_h.clone());
485                }
486            }
487        },
488    );
489}
490
491#[tessera]
492fn scrollable_inner(
493    args: impl Into<ScrollableArgs>,
494    state: Arc<RwLock<ScrollableStateInner>>,
495    scrollbar_state_v: Arc<RwLock<ScrollBarState>>,
496    scrollbar_state_h: Arc<RwLock<ScrollBarState>>,
497    child: impl FnOnce(),
498) {
499    let args: ScrollableArgs = args.into();
500    {
501        let state = state.clone();
502        measure(Box::new(move |input| {
503            // Merge constraints with parent constraints
504            let arg_constraint = Constraint {
505                width: args.width,
506                height: args.height,
507            };
508            let merged_constraint = input.parent_constraint.merge(&arg_constraint);
509            // Now calculate the constraints to child
510            let mut child_constraint = merged_constraint;
511            // If vertical scrollable, set height to wrap
512            if args.vertical {
513                child_constraint.height = tessera_ui::DimensionValue::Wrap {
514                    min: None,
515                    max: None,
516                };
517            }
518            // If horizontal scrollable, set width to wrap
519            if args.horizontal {
520                child_constraint.width = tessera_ui::DimensionValue::Wrap {
521                    min: None,
522                    max: None,
523                };
524            }
525            // Measure the child with child constraint
526            let child_node_id = input.children_ids[0]; // Scrollable should have exactly one child
527            let child_measurement = input.measure_child(child_node_id, &child_constraint)?;
528            // Update the child position and size in the state
529            state.write().child_size = child_measurement;
530
531            // Update scroll position based on time and get current position for rendering
532            let current_child_position = {
533                let mut state_guard = state.write();
534                state_guard.update_scroll_position(args.scroll_smoothing);
535                state_guard.child_position
536            };
537
538            // Place child at current interpolated position
539            input.place_child(child_node_id, current_child_position);
540            // Calculate the size of the scrollable area
541            let width = match merged_constraint.width {
542                DimensionValue::Fixed(w) => w,
543                DimensionValue::Wrap { min, max } => {
544                    let mut width = child_measurement.width;
545                    if let Some(min_width) = min {
546                        width = width.max(min_width);
547                    }
548                    if let Some(max_width) = max {
549                        width = width.min(max_width);
550                    }
551                    width
552                }
553                DimensionValue::Fill { min: _, max } => max.unwrap(),
554            };
555            let height = match merged_constraint.height {
556                DimensionValue::Fixed(h) => h,
557                DimensionValue::Wrap { min, max } => {
558                    let mut height = child_measurement.height;
559                    if let Some(min_height) = min {
560                        height = height.max(min_height)
561                    }
562                    if let Some(max_height) = max {
563                        height = height.min(max_height)
564                    }
565                    height
566                }
567                DimensionValue::Fill { min: _, max } => max.unwrap(),
568            };
569            // Pack the size into ComputedData
570            let computed_data = ComputedData { width, height };
571            // Update the visible size in the state
572            state.write().visible_size = computed_data;
573            // Return the size of the scrollable area
574            Ok(computed_data)
575        }));
576    }
577
578    // Handle scroll input and position updates
579    state_handler(Box::new(move |input| {
580        let size = input.computed_data;
581        let cursor_pos_option = input.cursor_position_rel;
582        let is_cursor_in_component = cursor_pos_option
583            .map(|pos| is_position_in_component(size, pos))
584            .unwrap_or(false);
585
586        if is_cursor_in_component {
587            // Handle scroll events
588            for event in input
589                .cursor_events
590                .iter()
591                .filter_map(|event| match &event.content {
592                    CursorEventContent::Scroll(event) => Some(event),
593                    _ => None,
594                })
595            {
596                let mut state_guard = state.write();
597
598                // Use scroll delta directly (speed already handled in cursor.rs)
599                let scroll_delta_x = event.delta_x;
600                let scroll_delta_y = event.delta_y;
601
602                // Calculate new target position using saturating arithmetic
603                let current_target = state_guard.target_position;
604                let new_target = current_target.saturating_offset(
605                    Px::saturating_from_f32(scroll_delta_x),
606                    Px::saturating_from_f32(scroll_delta_y),
607                );
608
609                // Apply bounds constraints immediately before setting target
610                let child_size = state_guard.child_size;
611                let constrained_target = constrain_position(
612                    new_target,
613                    &child_size,
614                    &input.computed_data,
615                    args.vertical,
616                    args.horizontal,
617                );
618
619                // Set constrained target position
620                state_guard.set_target_position(constrained_target);
621
622                // Update scroll activity for AutoHide behavior
623                if matches!(args.scrollbar_behavior, ScrollBarBehavior::AutoHide) {
624                    // Update vertical scrollbar state if vertical scrolling is enabled
625                    if args.vertical {
626                        let mut scrollbar_state = scrollbar_state_v.write();
627                        scrollbar_state.last_scroll_activity = Some(std::time::Instant::now());
628                        scrollbar_state.should_be_visible = true;
629                    }
630                    // Update horizontal scrollbar state if horizontal scrolling is enabled
631                    if args.horizontal {
632                        let mut scrollbar_state = scrollbar_state_h.write();
633                        scrollbar_state.last_scroll_activity = Some(std::time::Instant::now());
634                        scrollbar_state.should_be_visible = true;
635                    }
636                }
637            }
638
639            // Apply bound constraints to the child position
640            // To make sure we constrain the target position at least once per frame
641            let target = state.read().target_position;
642            let child_size = state.read().child_size;
643            let constrained_position = constrain_position(
644                target,
645                &child_size,
646                &input.computed_data,
647                args.vertical,
648                args.horizontal,
649            );
650            state.write().set_target_position(constrained_position);
651
652            // Block cursor events to prevent propagation
653            input.cursor_events.clear();
654        }
655
656        // Update scroll position based on time (only once per frame, after handling events)
657        state.write().update_scroll_position(args.scroll_smoothing);
658    }));
659
660    // Add child component
661    child();
662}
663
664/// Constrains a position to stay within the scrollable bounds
665fn constrain_position(
666    position: PxPosition,
667    child_size: &ComputedData,
668    container_size: &ComputedData,
669    vertical_scrollable: bool,
670    horizontal_scrollable: bool,
671) -> PxPosition {
672    let mut constrained = position;
673
674    // Only apply constraints for scrollable directions
675    if horizontal_scrollable {
676        // Check if left edge of the child is out of bounds
677        if constrained.x > Px::ZERO {
678            constrained.x = Px::ZERO;
679        }
680        // Check if right edge of the child is out of bounds
681        if constrained.x.saturating_add(child_size.width) < container_size.width {
682            constrained.x = container_size.width.saturating_sub(child_size.width);
683        }
684    } else {
685        // Not horizontally scrollable, keep at zero
686        constrained.x = Px::ZERO;
687    }
688
689    if vertical_scrollable {
690        // Check if top edge of the child is out of bounds
691        if constrained.y > Px::ZERO {
692            constrained.y = Px::ZERO;
693        }
694        // Check if bottom edge of the child is out of bounds
695        if constrained.y.saturating_add(child_size.height) < container_size.height {
696            constrained.y = container_size.height.saturating_sub(child_size.height);
697        }
698    } else {
699        // Not vertically scrollable, keep at zero
700        constrained.y = Px::ZERO;
701    }
702
703    constrained
704}