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