tessera_ui_basic_components/
scrollable.rs

1use std::{sync::Arc, time::Instant};
2
3use derive_builder::Builder;
4use parking_lot::RwLock;
5use tessera_ui::{
6    ComputedData, Constraint, CursorEventContent, DimensionValue, Px, PxPosition,
7    focus_state::Focus, measure_node, place_node,
8};
9use tessera_ui_macros::tessera;
10
11use crate::pos_misc::is_position_in_component;
12
13#[derive(Debug, Builder)]
14pub struct ScrollableArgs {
15    /// The desired width behavior of the scrollable area
16    /// Defaults to Wrap { min: None, max: None }.
17    #[builder(default = "tessera_ui::DimensionValue::Wrap { min: None, max: None }")]
18    pub width: tessera_ui::DimensionValue,
19    /// The desired height behavior of the scrollable area.
20    /// Defaults to Wrap { min: None, max: None }.
21    #[builder(default = "tessera_ui::DimensionValue::Wrap { min: None, max: None }")]
22    pub height: tessera_ui::DimensionValue,
23    /// Is vertical scrollable?
24    /// Defaults to `true` since most scrollable areas are vertical.
25    #[builder(default = "true")]
26    pub vertical: bool,
27    /// Is horizontal scrollable?
28    /// Defaults to `false` since most scrollable areas are not horizontal.
29    #[builder(default = "false")]
30    pub horizontal: bool,
31    /// Scroll smoothing factor (0.0 = instant, 1.0 = very smooth).
32    /// Defaults to 0.05 for very responsive but still smooth scrolling.
33    #[builder(default = "0.05")]
34    pub scroll_smoothing: f32,
35}
36
37/// The state of Scrollable.
38pub struct ScrollableState {
39    /// The current position of the child component (for rendering)
40    child_position: PxPosition,
41    /// The target position of the child component (scrolling destination)
42    target_position: PxPosition,
43    /// The child component size
44    child_size: ComputedData,
45    /// Last frame time for delta time calculation
46    last_frame_time: Option<Instant>,
47    /// Focus handler for the scrollable component
48    focus_handler: Focus,
49}
50
51impl Default for ScrollableState {
52    fn default() -> Self {
53        Self::new()
54    }
55}
56
57impl ScrollableState {
58    /// Creates a new ScrollableState with default values.
59    pub fn new() -> Self {
60        Self {
61            child_position: PxPosition::ZERO,
62            target_position: PxPosition::ZERO,
63            child_size: ComputedData::ZERO,
64            last_frame_time: None,
65            focus_handler: Focus::new(),
66        }
67    }
68
69    /// Updates the scroll position based on time-based interpolation
70    /// Returns true if the position changed (needs redraw)
71    fn update_scroll_position(&mut self, smoothing: f32) -> bool {
72        let current_time = Instant::now();
73
74        // Calculate delta time
75        let delta_time = if let Some(last_time) = self.last_frame_time {
76            current_time.duration_since(last_time).as_secs_f32()
77        } else {
78            0.016 // Assume 60fps for first frame
79        };
80
81        self.last_frame_time = Some(current_time);
82
83        // Calculate the difference between target and current position
84        let diff_x = self.target_position.x.to_f32() - self.child_position.x.to_f32();
85        let diff_y = self.target_position.y.to_f32() - self.child_position.y.to_f32();
86
87        // If we're close enough to target, snap to it
88        if diff_x.abs() < 1.0 && diff_y.abs() < 1.0 {
89            if self.child_position != self.target_position {
90                self.child_position = self.target_position;
91                return true;
92            }
93            return false;
94        }
95
96        // Use simple velocity-based movement for consistent behavior
97        // Higher smoothing = slower movement
98        let mut movement_factor = (1.0 - smoothing) * delta_time * 60.0;
99
100        // CRITICAL FIX: Clamp the movement factor to a maximum of 1.0.
101        // A factor greater than 1.0 causes the interpolation to overshoot the target,
102        // leading to oscillations that grow exponentially, causing the value explosion
103        // and overflow panic seen in the logs. Clamping ensures stability by
104        // preventing the position from moving past the target in a single frame.
105        if movement_factor > 1.0 {
106            movement_factor = 1.0;
107        }
108        let old_position = self.child_position;
109
110        self.child_position = PxPosition {
111            x: Px::saturating_from_f32(self.child_position.x.to_f32() + diff_x * movement_factor),
112            y: Px::saturating_from_f32(self.child_position.y.to_f32() + diff_y * movement_factor),
113        };
114
115        // Return true if position changed significantly
116        old_position != self.child_position
117    }
118
119    /// Sets a new target position for scrolling
120    fn set_target_position(&mut self, target: PxPosition) {
121        self.target_position = target;
122    }
123
124    /// Gets a reference to the focus handler
125    pub fn focus_handler(&self) -> &Focus {
126        &self.focus_handler
127    }
128
129    /// Gets a mutable reference to the focus handler
130    pub fn focus_handler_mut(&mut self) -> &mut Focus {
131        &mut self.focus_handler
132    }
133}
134
135#[tessera]
136pub fn scrollable(
137    args: impl Into<ScrollableArgs>,
138    state: Arc<RwLock<ScrollableState>>,
139    child: impl FnOnce(),
140) {
141    let args: ScrollableArgs = args.into();
142    {
143        let state = state.clone();
144        measure(Box::new(move |input| {
145            // Merge constraints with parent constraints
146            let arg_constraint = Constraint {
147                width: args.width,
148                height: args.height,
149            };
150            let merged_constraint = input.parent_constraint.merge(&arg_constraint);
151            // Now calculate the constraints to child
152            let mut child_constraint = merged_constraint;
153            // If vertical scrollable, set height to wrap
154            if args.vertical {
155                child_constraint.height = tessera_ui::DimensionValue::Wrap {
156                    min: None,
157                    max: None,
158                };
159            }
160            // If horizontal scrollable, set width to wrap
161            if args.horizontal {
162                child_constraint.width = tessera_ui::DimensionValue::Wrap {
163                    min: None,
164                    max: None,
165                };
166            }
167            // Measure the child with child constraint
168            let child_node_id = input.children_ids[0]; // Scrollable should have exactly one child
169            let child_measurement = measure_node(
170                child_node_id,
171                &child_constraint,
172                input.tree,
173                input.metadatas,
174                input.compute_resource_manager.clone(),
175                input.gpu,
176            )?;
177            // Update the child position and size in the state
178            state.write().child_size = child_measurement;
179
180            // Update scroll position based on time and get current position for rendering
181            let current_child_position = {
182                let mut state_guard = state.write();
183                state_guard.update_scroll_position(args.scroll_smoothing);
184                state_guard.child_position
185            };
186
187            // Place child at current interpolated position
188            place_node(child_node_id, current_child_position, input.metadatas);
189            // Calculate the size of the scrollable area
190            let width = match merged_constraint.width {
191                DimensionValue::Fixed(w) => w,
192                DimensionValue::Wrap { min, max } => {
193                    let mut width = child_measurement.width;
194                    if let Some(min_width) = min {
195                        width = width.max(min_width);
196                    }
197                    if let Some(max_width) = max {
198                        width = width.min(max_width);
199                    }
200                    width
201                }
202                DimensionValue::Fill { min: _, max } => max.unwrap(),
203            };
204            let height = match merged_constraint.height {
205                DimensionValue::Fixed(h) => h,
206                DimensionValue::Wrap { min, max } => {
207                    let mut height = child_measurement.height;
208                    if let Some(min_height) = min {
209                        height = height.max(min_height)
210                    }
211                    if let Some(max_height) = max {
212                        height = height.min(max_height)
213                    }
214                    height
215                }
216                DimensionValue::Fill { min: _, max } => max.unwrap(),
217            };
218            // Return the size of the scrollable area
219            Ok(ComputedData { width, height })
220        }));
221    }
222
223    // Handle scroll input and position updates
224    state_handler(Box::new(move |input| {
225        let size = input.computed_data;
226        let cursor_pos_option = input.cursor_position;
227        let is_cursor_in_component = cursor_pos_option
228            .map(|pos| is_position_in_component(size, pos))
229            .unwrap_or(false);
230
231        // Handle click events to request focus (only when cursor is in component)
232        if is_cursor_in_component {
233            let click_events: Vec<_> = input
234                .cursor_events
235                .iter()
236                .filter(|event| matches!(event.content, CursorEventContent::Pressed(_)))
237                .collect();
238
239            if !click_events.is_empty() {
240                // Request focus if not already focused
241                if !state.read().focus_handler().is_focused() {
242                    state.write().focus_handler_mut().request_focus();
243                }
244            }
245
246            // Handle scroll events (only when focused)
247            if state.read().focus_handler().is_focused() {
248                for event in input
249                    .cursor_events
250                    .iter()
251                    .filter_map(|event| match &event.content {
252                        CursorEventContent::Scroll(event) => Some(event),
253                        _ => None,
254                    })
255                {
256                    let mut state_guard = state.write();
257
258                    // Use scroll delta directly (speed already handled in cursor.rs)
259                    let scroll_delta_x = event.delta_x;
260                    let scroll_delta_y = event.delta_y;
261
262                    // Calculate new target position using saturating arithmetic
263                    let current_target = state_guard.target_position;
264                    let new_target = current_target.saturating_offset(
265                        Px::saturating_from_f32(scroll_delta_x),
266                        Px::saturating_from_f32(scroll_delta_y),
267                    );
268
269                    // Apply bounds constraints immediately before setting target
270                    let child_size = state_guard.child_size;
271                    let constrained_target = constrain_position(
272                        new_target,
273                        &child_size,
274                        &input.computed_data,
275                        args.vertical,
276                        args.horizontal,
277                    );
278
279                    // Set constrained target position
280                    state_guard.set_target_position(constrained_target);
281                }
282            }
283
284            // Apply bound constraints to the child position
285            // To make sure we constrain the target position at least once per frame
286            let target = state.read().target_position;
287            let child_size = state.read().child_size;
288            let constrained_position = constrain_position(
289                target,
290                &child_size,
291                &input.computed_data,
292                args.vertical,
293                args.horizontal,
294            );
295            state.write().set_target_position(constrained_position);
296
297            // Only block cursor events when focused to prevent propagation
298            if state.read().focus_handler().is_focused() {
299                input.cursor_events.clear();
300            }
301        }
302
303        // Update scroll position based on time (only once per frame, after handling events)
304        state.write().update_scroll_position(args.scroll_smoothing);
305    }));
306
307    // Add child component
308    child();
309}
310
311/// Constrains a position to stay within the scrollable bounds
312fn constrain_position(
313    position: PxPosition,
314    child_size: &ComputedData,
315    container_size: &ComputedData,
316    vertical_scrollable: bool,
317    horizontal_scrollable: bool,
318) -> PxPosition {
319    let mut constrained = position;
320
321    // Only apply constraints for scrollable directions
322    if horizontal_scrollable {
323        // Check if left edge of the child is out of bounds
324        if constrained.x > Px::ZERO {
325            constrained.x = Px::ZERO;
326        }
327        // Check if right edge of the child is out of bounds
328        if constrained.x.saturating_add(child_size.width) < container_size.width {
329            constrained.x = container_size.width.saturating_sub(child_size.width);
330        }
331    } else {
332        // Not horizontally scrollable, keep at zero
333        constrained.x = Px::ZERO;
334    }
335
336    if vertical_scrollable {
337        // Check if top edge of the child is out of bounds
338        if constrained.y > Px::ZERO {
339            constrained.y = Px::ZERO;
340        }
341        // Check if bottom edge of the child is out of bounds
342        if constrained.y.saturating_add(child_size.height) < container_size.height {
343            constrained.y = container_size.height.saturating_sub(child_size.height);
344        }
345    } else {
346        // Not vertically scrollable, keep at zero
347        constrained.y = Px::ZERO;
348    }
349
350    constrained
351}