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}