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;
18use std::{sync::Arc, time::Instant};
19
20use derive_builder::Builder;
21use parking_lot::RwLock;
22use tessera_ui::{
23 Color, ComputedData, Constraint, CursorEventContent, DimensionValue, Dp, Px, PxPosition,
24 tessera,
25};
26
27use crate::{
28 alignment::Alignment,
29 boxed::{BoxedArgsBuilder, boxed},
30 pos_misc::is_position_in_component,
31 scrollable::scrollbar::{ScrollBarArgs, ScrollBarState, scrollbar_h, scrollbar_v},
32};
33
34#[derive(Debug, Builder, Clone)]
35pub struct ScrollableArgs {
36 /// The desired width behavior of the scrollable area
37 /// Defaults to Wrap { min: None, max: None }.
38 #[builder(default = "tessera_ui::DimensionValue::Wrap { min: None, max: None }")]
39 pub width: tessera_ui::DimensionValue,
40 /// The desired height behavior of the scrollable area.
41 /// Defaults to Wrap { min: None, max: None }.
42 #[builder(default = "tessera_ui::DimensionValue::Wrap { min: None, max: None }")]
43 pub height: tessera_ui::DimensionValue,
44 /// Is vertical scrollable?
45 /// Defaults to `true` since most scrollable areas are vertical.
46 #[builder(default = "true")]
47 pub vertical: bool,
48 /// Is horizontal scrollable?
49 /// Defaults to `false` since most scrollable areas are not horizontal.
50 #[builder(default = "false")]
51 pub horizontal: bool,
52 /// Scroll smoothing factor (0.0 = instant, 1.0 = very smooth).
53 /// Defaults to 0.05 for very responsive but still smooth scrolling.
54 #[builder(default = "0.05")]
55 pub scroll_smoothing: f32,
56 /// The behavior of the scrollbar visibility.
57 #[builder(default = "ScrollBarBehavior::AlwaysVisible")]
58 pub scrollbar_behavior: ScrollBarBehavior,
59 /// The color of the scrollbar track.
60 #[builder(default = "Color::new(0.0, 0.0, 0.0, 0.1)")]
61 pub scrollbar_track_color: Color,
62 /// The color of the scrollbar thumb.
63 #[builder(default = "Color::new(0.0, 0.0, 0.0, 0.3)")]
64 pub scrollbar_thumb_color: Color,
65 /// The color of the scrollbar thumb when hovered.
66 #[builder(default = "Color::new(0.0, 0.0, 0.0, 0.5)")]
67 pub scrollbar_thumb_hover_color: Color,
68 /// The layout of the scrollbar relative to the content.
69 #[builder(default = "ScrollBarLayout::Alongside")]
70 pub scrollbar_layout: ScrollBarLayout,
71}
72
73/// Defines the behavior of the scrollbar visibility.
74#[derive(Debug, Clone)]
75pub enum ScrollBarBehavior {
76 /// The scrollbar is always visible.
77 AlwaysVisible,
78 /// The scrollbar is only visible when scrolling.
79 AutoHide,
80 /// No scrollbar at all.
81 Hidden,
82}
83
84/// Defines the layout of the scrollbar relative to the scrollable content.
85#[derive(Debug, Clone)]
86pub enum ScrollBarLayout {
87 /// The scrollbar is placed alongside the content (takes up space in the layout).
88 Alongside,
89 /// The scrollbar is overlaid on top of the content (doesn't take up space).
90 Overlay,
91}
92
93impl Default for ScrollableArgs {
94 fn default() -> Self {
95 ScrollableArgsBuilder::default().build().unwrap()
96 }
97}
98
99/// Holds the state for a `scrollable` component, managing scroll position and interaction.
100///
101/// It tracks the current and target scroll positions, the size of the scrollable content, and focus state.
102///
103/// The scroll position is smoothly interpolated over time to create a fluid scrolling effect.
104#[derive(Default)]
105pub struct ScrollableState {
106 /// The inner state containing scroll position, size
107 inner: Arc<RwLock<ScrollableStateInner>>,
108 /// The state for vertical scrollbar
109 scrollbar_state_v: Arc<RwLock<ScrollBarState>>,
110 /// The state for horizontal scrollbar
111 scrollbar_state_h: Arc<RwLock<ScrollBarState>>,
112}
113
114impl ScrollableState {
115 /// Creates a new `ScrollableState` with default values.
116 pub fn new() -> Self {
117 Self::default()
118 }
119}
120
121#[derive(Clone, Debug)]
122struct ScrollableStateInner {
123 /// The current position of the child component (for rendering)
124 child_position: PxPosition,
125 /// The target position of the child component (scrolling destination)
126 target_position: PxPosition,
127 /// The child component size
128 child_size: ComputedData,
129 /// The visible area size
130 visible_size: ComputedData,
131 /// Last frame time for delta time calculation
132 last_frame_time: Option<Instant>,
133}
134
135impl Default for ScrollableStateInner {
136 fn default() -> Self {
137 Self::new()
138 }
139}
140
141impl ScrollableStateInner {
142 /// Creates a new ScrollableState with default values.
143 pub fn new() -> Self {
144 Self {
145 child_position: PxPosition::ZERO,
146 target_position: PxPosition::ZERO,
147 child_size: ComputedData::ZERO,
148 visible_size: ComputedData::ZERO,
149 last_frame_time: None,
150 }
151 }
152
153 /// Updates the scroll position based on time-based interpolation
154 /// Returns true if the position changed (needs redraw)
155 fn update_scroll_position(&mut self, smoothing: f32) -> bool {
156 let current_time = Instant::now();
157
158 // Calculate delta time
159 let delta_time = if let Some(last_time) = self.last_frame_time {
160 current_time.duration_since(last_time).as_secs_f32()
161 } else {
162 0.016 // Assume 60fps for first frame
163 };
164
165 self.last_frame_time = Some(current_time);
166
167 // Calculate the difference between target and current position
168 let diff_x = self.target_position.x.to_f32() - self.child_position.x.to_f32();
169 let diff_y = self.target_position.y.to_f32() - self.child_position.y.to_f32();
170
171 // If we're close enough to target, snap to it
172 if diff_x.abs() < 1.0 && diff_y.abs() < 1.0 {
173 if self.child_position != self.target_position {
174 self.child_position = self.target_position;
175 return true;
176 }
177 return false;
178 }
179
180 // Use simple velocity-based movement for consistent behavior
181 // Higher smoothing = slower movement
182 let mut movement_factor = (1.0 - smoothing) * delta_time * 60.0;
183
184 // CRITICAL FIX: Clamp the movement factor to a maximum of 1.0.
185 // A factor greater than 1.0 causes the interpolation to overshoot the target,
186 // leading to oscillations that grow exponentially, causing the value explosion
187 // and overflow panic seen in the logs. Clamping ensures stability by
188 // preventing the position from moving past the target in a single frame.
189 if movement_factor > 1.0 {
190 movement_factor = 1.0;
191 }
192 let old_position = self.child_position;
193
194 self.child_position = PxPosition {
195 x: Px::saturating_from_f32(self.child_position.x.to_f32() + diff_x * movement_factor),
196 y: Px::saturating_from_f32(self.child_position.y.to_f32() + diff_y * movement_factor),
197 };
198
199 // Return true if position changed significantly
200 old_position != self.child_position
201 }
202
203 /// Sets a new target position for scrolling
204 fn set_target_position(&mut self, target: PxPosition) {
205 self.target_position = target;
206 }
207}
208
209/// A container that makes its content scrollable when it exceeds the container's size.
210///
211/// The `scrollable` component is a fundamental building block for creating areas with
212/// content that may not fit within the allocated space. It supports vertical and/or
213/// horizontal scrolling, which can be configured via `ScrollableArgs`.
214///
215/// The component offers two scrollbar layout options:
216/// - `Alongside`: Scrollbars take up space in the layout alongside the content
217/// - `Overlay`: Scrollbars are overlaid on top of the content without taking up space
218///
219/// State management is handled by `ScrollableState`, which must be provided to persist
220/// the scroll position across recompositions. The scrolling behavior is animated with
221/// a configurable smoothing factor for a better user experience.
222///
223/// # Example
224///
225/// ```
226/// use std::sync::Arc;
227/// use parking_lot::RwLock;
228/// use tessera_ui::{DimensionValue, Dp};
229/// use tessera_ui_basic_components::{
230/// column::{column, ColumnArgs},
231/// scrollable::{scrollable, ScrollableArgs, ScrollableState, ScrollBarLayout},
232/// text::text,
233/// };
234///
235/// // In a real app, you would manage the state.
236/// let scrollable_state = Arc::new(ScrollableState::new());
237///
238/// // Example with alongside scrollbars (default)
239/// scrollable(
240/// ScrollableArgs {
241/// height: DimensionValue::Fixed(Dp(100.0).into()),
242/// scrollbar_layout: ScrollBarLayout::Alongside,
243/// ..Default::default()
244/// },
245/// scrollable_state.clone(),
246/// || {
247/// column(ColumnArgs::default(), |scope| {
248/// scope.child(|| text("Item 1".to_string()));
249/// scope.child(|| text("Item 2".to_string()));
250/// scope.child(|| text("Item 3".to_string()));
251/// scope.child(|| text("Item 4".to_string()));
252/// scope.child(|| text("Item 5".to_string()));
253/// scope.child(|| text("Item 6".to_string()));
254/// scope.child(|| text("Item 7".to_string()));
255/// scope.child(|| text("Item 8".to_string()));
256/// scope.child(|| text("Item 9".to_string()));
257/// scope.child(|| text("Item 10".to_string()));
258/// });
259/// },
260/// );
261///
262/// // Example with overlay scrollbars
263/// scrollable(
264/// ScrollableArgs {
265/// height: DimensionValue::Fixed(Dp(100.0).into()),
266/// scrollbar_layout: ScrollBarLayout::Overlay,
267/// ..Default::default()
268/// },
269/// scrollable_state,
270/// || {
271/// column(ColumnArgs::default(), |scope| {
272/// scope.child(|| text("Item 1".to_string()));
273/// scope.child(|| text("Item 2".to_string()));
274/// scope.child(|| text("Item 3".to_string()));
275/// scope.child(|| text("Item 4".to_string()));
276/// scope.child(|| text("Item 5".to_string()));
277/// scope.child(|| text("Item 6".to_string()));
278/// scope.child(|| text("Item 7".to_string()));
279/// scope.child(|| text("Item 8".to_string()));
280/// scope.child(|| text("Item 9".to_string()));
281/// scope.child(|| text("Item 10".to_string()));
282/// });
283/// },
284/// );
285/// ```
286///
287/// # Panics
288///
289/// This component will panic if it does not have exactly one child.
290///
291/// # Arguments
292///
293/// * `args`: An instance of `ScrollableArgs` or `ScrollableArgsBuilder` to configure the
294/// scrollable area's behavior, such as dimensions and scroll directions.
295/// * `state`: An `Arc<RwLock<ScrollableState>>` to hold and manage the component's state.
296/// * `child`: A closure that defines the content to be placed inside the scrollable container.
297/// This closure is executed once to build the component tree.
298#[tessera]
299pub fn scrollable(
300 args: impl Into<ScrollableArgs>,
301 state: Arc<ScrollableState>,
302 child: impl FnOnce() + Send + Sync + 'static,
303) {
304 let args: ScrollableArgs = args.into();
305
306 // Create separate ScrollBarArgs for vertical and horizontal scrollbars
307 let scrollbar_args_v = ScrollBarArgs {
308 total: state.inner.read().child_size.height,
309 visible: state.inner.read().visible_size.height,
310 offset: state.inner.read().child_position.y,
311 thickness: Dp(8.0), // Default scrollbar thickness
312 state: state.inner.clone(),
313 scrollbar_behavior: args.scrollbar_behavior.clone(),
314 track_color: args.scrollbar_track_color,
315 thumb_color: args.scrollbar_thumb_color,
316 thumb_hover_color: args.scrollbar_thumb_hover_color,
317 };
318
319 let scrollbar_args_h = ScrollBarArgs {
320 total: state.inner.read().child_size.width,
321 visible: state.inner.read().visible_size.width,
322 offset: state.inner.read().child_position.x,
323 thickness: Dp(8.0), // Default scrollbar thickness
324 state: state.inner.clone(),
325 scrollbar_behavior: args.scrollbar_behavior.clone(),
326 track_color: args.scrollbar_track_color,
327 thumb_color: args.scrollbar_thumb_color,
328 thumb_hover_color: args.scrollbar_thumb_hover_color,
329 };
330
331 match args.scrollbar_layout {
332 ScrollBarLayout::Alongside => {
333 scrollable_with_alongside_scrollbar(
334 state,
335 args,
336 scrollbar_args_v,
337 scrollbar_args_h,
338 child,
339 );
340 }
341 ScrollBarLayout::Overlay => {
342 scrollable_with_overlay_scrollbar(
343 state,
344 args,
345 scrollbar_args_v,
346 scrollbar_args_h,
347 child,
348 );
349 }
350 }
351}
352
353#[tessera]
354fn scrollable_with_alongside_scrollbar(
355 state: Arc<ScrollableState>,
356 args: ScrollableArgs,
357 scrollbar_args_v: ScrollBarArgs,
358 scrollbar_args_h: ScrollBarArgs,
359 child: impl FnOnce() + Send + Sync + 'static,
360) {
361 scrollable_inner(
362 args.clone(),
363 state.inner.clone(),
364 state.scrollbar_state_v.clone(),
365 state.scrollbar_state_h.clone(),
366 child,
367 );
368
369 if args.vertical {
370 scrollbar_v(scrollbar_args_v, state.scrollbar_state_v.clone());
371 }
372
373 if args.horizontal {
374 scrollbar_h(scrollbar_args_h, state.scrollbar_state_h.clone());
375 }
376
377 measure(Box::new(move |input| {
378 // Record the final size
379 let mut final_size = ComputedData::ZERO;
380 // Merge arg constraints with parent constraints
381 let self_constraint = Constraint {
382 width: args.width,
383 height: args.height,
384 };
385 let mut content_contraint = self_constraint.merge(input.parent_constraint);
386 // measure the scrollbar
387 if args.vertical {
388 let scrollbar_node_id = input.children_ids[1];
389 let size = input.measure_child(scrollbar_node_id, input.parent_constraint)?;
390 // substract the scrollbar size from the content constraint
391 content_contraint.width -= size.width;
392 // update the size
393 final_size.width += size.width;
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 let size = input.measure_child(scrollbar_node_id, input.parent_constraint)?;
402 content_contraint.height -= size.height;
403 // update the size
404 final_size.height += size.height;
405 }
406 // Measure the content
407 let content_node_id = input.children_ids[0];
408 let content_measurement = input.measure_child(content_node_id, &content_contraint)?;
409 // update the size
410 final_size.width += content_measurement.width;
411 final_size.height += content_measurement.height;
412 // Place childrens
413 // place the content at [0, 0]
414 input.place_child(content_node_id, PxPosition::ZERO);
415 // place the scrollbar at the end
416 if args.vertical {
417 input.place_child(
418 input.children_ids[1],
419 PxPosition::new(content_measurement.width, Px::ZERO),
420 );
421 }
422 if args.horizontal {
423 let scrollbar_node_id = if args.vertical {
424 input.children_ids[2]
425 } else {
426 input.children_ids[1]
427 };
428 input.place_child(
429 scrollbar_node_id,
430 PxPosition::new(Px::ZERO, content_measurement.height),
431 );
432 }
433 // Return the computed data
434 Ok(final_size)
435 }));
436}
437
438#[tessera]
439fn scrollable_with_overlay_scrollbar(
440 state: Arc<ScrollableState>,
441 args: ScrollableArgs,
442 scrollbar_args_v: ScrollBarArgs,
443 scrollbar_args_h: ScrollBarArgs,
444 child: impl FnOnce() + Send + Sync + 'static,
445) {
446 boxed(
447 BoxedArgsBuilder::default()
448 .width(args.width)
449 .height(args.height)
450 .alignment(Alignment::BottomEnd)
451 .build()
452 .unwrap(),
453 |scope| {
454 scope.child({
455 let state = state.clone();
456 let args = args.clone();
457 move || {
458 scrollable_inner(
459 args,
460 state.inner.clone(),
461 state.scrollbar_state_v.clone(),
462 state.scrollbar_state_h.clone(),
463 child,
464 );
465 }
466 });
467 scope.child({
468 let scrollbar_args_v = scrollbar_args_v.clone();
469 let args = args.clone();
470 let state = state.clone();
471 move || {
472 if args.vertical {
473 scrollbar_v(scrollbar_args_v, state.scrollbar_state_v.clone());
474 }
475 }
476 });
477 scope.child({
478 let scrollbar_args_h = scrollbar_args_h.clone();
479 let args = args.clone();
480 let state = state.clone();
481 move || {
482 if args.horizontal {
483 scrollbar_h(scrollbar_args_h, state.scrollbar_state_h.clone());
484 }
485 }
486 });
487 },
488 );
489}
490
491// Helpers to resolve DimensionValue into concrete Px sizes.
492// This reduces duplication in the measurement code and lowers cyclomatic complexity.
493fn clamp_wrap(min: Option<Px>, max: Option<Px>, measure: Px) -> Px {
494 min.unwrap_or(Px(0))
495 .max(measure)
496 .min(max.unwrap_or(Px::MAX))
497}
498
499fn fill_value(min: Option<Px>, max: Option<Px>, measure: Px) -> Px {
500 max.expect("Seems that you are trying to fill an infinite dimension, which is not allowed")
501 .max(measure)
502 .max(min.unwrap_or(Px(0)))
503}
504
505fn resolve_dimension(dim: DimensionValue, measure: Px) -> Px {
506 match dim {
507 DimensionValue::Fixed(v) => v,
508 DimensionValue::Wrap { min, max } => clamp_wrap(min, max, measure),
509 DimensionValue::Fill { min, max } => fill_value(min, max, measure),
510 }
511}
512
513#[tessera]
514fn scrollable_inner(
515 args: impl Into<ScrollableArgs>,
516 state: Arc<RwLock<ScrollableStateInner>>,
517 scrollbar_state_v: Arc<RwLock<ScrollBarState>>,
518 scrollbar_state_h: Arc<RwLock<ScrollBarState>>,
519 child: impl FnOnce(),
520) {
521 let args: ScrollableArgs = args.into();
522 {
523 let state = state.clone();
524 measure(Box::new(move |input| {
525 // Enable clip
526 input.enable_clipping();
527 // Merge constraints with parent constraints
528 let arg_constraint = Constraint {
529 width: args.width,
530 height: args.height,
531 };
532 let merged_constraint = input.parent_constraint.merge(&arg_constraint);
533 // Now calculate the constraints to child
534 let mut child_constraint = merged_constraint;
535 // If vertical scrollable, set height to wrap
536 if args.vertical {
537 child_constraint.height = tessera_ui::DimensionValue::Wrap {
538 min: None,
539 max: None,
540 };
541 }
542 // If horizontal scrollable, set width to wrap
543 if args.horizontal {
544 child_constraint.width = tessera_ui::DimensionValue::Wrap {
545 min: None,
546 max: None,
547 };
548 }
549 // Measure the child with child constraint
550 let child_node_id = input.children_ids[0]; // Scrollable should have exactly one child
551 let child_measurement = input.measure_child(child_node_id, &child_constraint)?;
552 // Update the child position and size in the state
553 state.write().child_size = child_measurement;
554
555 // Update scroll position based on time and get current position for rendering
556 let current_child_position = {
557 let mut state_guard = state.write();
558 state_guard.update_scroll_position(args.scroll_smoothing);
559 state_guard.child_position
560 };
561
562 // Place child at current interpolated position
563 input.place_child(child_node_id, current_child_position);
564
565 // Calculate the size of the scrollable area using helpers to reduce inline branching
566 let width = resolve_dimension(merged_constraint.width, child_measurement.width);
567 let height = resolve_dimension(merged_constraint.height, child_measurement.height);
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 input_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.
665///
666/// Split per-axis logic into a helper to simplify reasoning and reduce cyclomatic complexity.
667fn constrain_axis(pos: Px, child_len: Px, container_len: Px) -> Px {
668 if pos > Px::ZERO {
669 Px::ZERO
670 } else if pos.saturating_add(child_len) < container_len {
671 container_len.saturating_sub(child_len)
672 } else {
673 pos
674 }
675}
676
677fn constrain_position(
678 position: PxPosition,
679 child_size: &ComputedData,
680 container_size: &ComputedData,
681 vertical_scrollable: bool,
682 horizontal_scrollable: bool,
683) -> PxPosition {
684 let x = if horizontal_scrollable {
685 constrain_axis(position.x, child_size.width, container_size.width)
686 } else {
687 Px::ZERO
688 };
689
690 let y = if vertical_scrollable {
691 constrain_axis(position.y, child_size.height, container_size.height)
692 } else {
693 Px::ZERO
694 };
695
696 PxPosition { x, y }
697}