tessera_ui_basic_components/
scrollable.rs

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