Skip to main content

cranpose_ui/
scroll.rs

1//! Scroll state and node implementation for cranpose.
2//!
3//! This module provides the core scrolling components:
4//! - `ScrollState`: Holds scroll position and provides scroll control methods
5//! - `ScrollNode`: Layout modifier that applies scroll offset to content
6//! - `ScrollElement`: Element for creating ScrollNode instances
7//!
8//! The actual `Modifier.horizontal_scroll()` and `Modifier.vertical_scroll()`
9//! extension methods are defined in `modifier/scroll.rs`.
10
11use cranpose_core::{mutableStateOf, MutableState, NodeId};
12use cranpose_foundation::{
13    Constraints, DelegatableNode, LayoutModifierNode, Measurable, ModifierNode,
14    ModifierNodeContext, ModifierNodeElement, NodeCapabilities, NodeState,
15};
16use cranpose_ui_graphics::Size;
17use cranpose_ui_layout::LayoutModifierMeasureResult;
18use std::cell::{Cell, RefCell};
19use std::hash::{DefaultHasher, Hash, Hasher};
20use std::rc::Rc;
21use std::sync::atomic::{AtomicU64, Ordering};
22
23static NEXT_SCROLL_STATE_ID: AtomicU64 = AtomicU64::new(1);
24
25/// State object for scroll position tracking.
26///
27/// Holds the current scroll offset and provides methods to programmatically
28/// control scrolling. Can be created with `rememberScrollState()`.
29///
30/// This is a pure scroll model - it does NOT store ephemeral gesture/pointer state.
31/// Gesture state is managed locally in the scroll modifier.
32#[derive(Clone)]
33pub struct ScrollState {
34    inner: Rc<ScrollStateInner>,
35}
36
37pub(crate) struct ScrollStateInner {
38    /// Unique ID for debugging
39    id: u64,
40    /// Current scroll offset in pixels.
41    /// Uses MutableState<f32> for reactivity - Composables can observe this value.
42    /// Layout reads use get_non_reactive() to avoid triggering recomposition.
43    value: MutableState<f32>,
44    /// Maximum scroll value (content_size - viewport_size)
45    /// Using RefCell instead of MutableState to avoid snapshot isolation issues
46    max_value: RefCell<f32>,
47    /// Callbacks to invalidate layout when scroll value changes
48    /// Using HashMap to allow multiple listeners (e.g. real node + clones)
49    invalidate_callbacks: RefCell<std::collections::HashMap<u64, Box<dyn Fn()>>>,
50    /// Tracks whether we need to invalidate once a callback is registered.
51    pending_invalidation: Cell<bool>,
52}
53
54impl ScrollState {
55    /// Creates a new ScrollState with the given initial scroll position.
56    pub fn new(initial: f32) -> Self {
57        let id = NEXT_SCROLL_STATE_ID.fetch_add(1, Ordering::Relaxed);
58
59        Self {
60            inner: Rc::new(ScrollStateInner {
61                id,
62                value: mutableStateOf(initial),
63                max_value: RefCell::new(0.0),
64                invalidate_callbacks: RefCell::new(std::collections::HashMap::new()),
65                pending_invalidation: Cell::new(false),
66            }),
67        }
68    }
69
70    /// Get the unique ID of this ScrollState
71    pub fn id(&self) -> u64 {
72        self.inner.id
73    }
74
75    /// Gets the current scroll position in pixels (reactive - triggers recomposition).
76    ///
77    /// Use this in Composable functions when you want UI to update on scroll.
78    /// Example: `Text("Scroll position: ${scrollState.value()}")`
79    pub fn value(&self) -> f32 {
80        self.inner.value.with(|v| *v)
81    }
82
83    /// Gets the current scroll position in pixels (non-reactive).
84    ///
85    /// Use this in layout/measure phase to avoid triggering recomposition.
86    /// This is called internally by ScrollNode::measure().
87    pub fn value_non_reactive(&self) -> f32 {
88        self.inner.value.get_non_reactive()
89    }
90
91    /// Gets the maximum scroll value.
92    pub fn max_value(&self) -> f32 {
93        *self.inner.max_value.borrow()
94    }
95
96    /// Scrolls by the given delta, clamping to valid range [0, max_value].
97    /// Returns the actual amount scrolled.
98    pub fn dispatch_raw_delta(&self, delta: f32) -> f32 {
99        let current = self.value();
100        let max = self.max_value();
101        let new_value = (current + delta).clamp(0.0, max);
102        let actual_delta = new_value - current;
103
104        if actual_delta.abs() > 0.001 {
105            // Use MutableState::set which triggers snapshot observers for reactive updates
106            self.inner.value.set(new_value);
107
108            // Trigger layout invalidation callbacks
109            let callbacks = self.inner.invalidate_callbacks.borrow();
110            if callbacks.is_empty() {
111                // Defer invalidation until a node registers a callback.
112                self.inner.pending_invalidation.set(true);
113            } else {
114                for callback in callbacks.values() {
115                    callback();
116                }
117            }
118        }
119
120        actual_delta
121    }
122
123    /// Sets the maximum scroll value (internal use by ScrollNode).
124    pub(crate) fn set_max_value(&self, max: f32) {
125        *self.inner.max_value.borrow_mut() = max;
126    }
127
128    /// Scrolls to the given position immediately.
129    pub fn scroll_to(&self, position: f32) {
130        let max = self.max_value();
131        let clamped = position.clamp(0.0, max);
132
133        self.inner.value.set(clamped);
134
135        // Trigger layout invalidation callbacks
136        let callbacks = self.inner.invalidate_callbacks.borrow();
137        if callbacks.is_empty() {
138            self.inner.pending_invalidation.set(true);
139        } else {
140            for callback in callbacks.values() {
141                callback();
142            }
143        }
144    }
145
146    /// Adds an invalidation callback and returns its ID
147    pub(crate) fn add_invalidate_callback(&self, callback: Box<dyn Fn()>) -> u64 {
148        static NEXT_CALLBACK_ID: std::sync::atomic::AtomicU64 =
149            std::sync::atomic::AtomicU64::new(1);
150        let id = NEXT_CALLBACK_ID.fetch_add(1, Ordering::Relaxed);
151        self.inner
152            .invalidate_callbacks
153            .borrow_mut()
154            .insert(id, callback);
155        if self.inner.pending_invalidation.replace(false) {
156            if let Some(callback) = self.inner.invalidate_callbacks.borrow().get(&id) {
157                callback();
158            }
159        }
160        id
161    }
162
163    /// Removes an invalidation callback by ID
164    pub(crate) fn remove_invalidate_callback(&self, id: u64) {
165        self.inner.invalidate_callbacks.borrow_mut().remove(&id);
166    }
167}
168
169/// Element for creating a ScrollNode.
170#[derive(Clone)]
171pub struct ScrollElement {
172    state: ScrollState,
173    is_vertical: bool,
174    reverse_scrolling: bool,
175}
176
177impl ScrollElement {
178    pub fn new(state: ScrollState, is_vertical: bool, reverse_scrolling: bool) -> Self {
179        Self {
180            state,
181            is_vertical,
182            reverse_scrolling,
183        }
184    }
185}
186
187impl std::fmt::Debug for ScrollElement {
188    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189        f.debug_struct("ScrollElement")
190            .field("is_vertical", &self.is_vertical)
191            .field("reverse_scrolling", &self.reverse_scrolling)
192            .finish()
193    }
194}
195
196impl PartialEq for ScrollElement {
197    fn eq(&self, other: &Self) -> bool {
198        // ScrollStates are equal if they point to the same underlying state
199        Rc::ptr_eq(&self.state.inner, &other.state.inner)
200            && self.is_vertical == other.is_vertical
201            && self.reverse_scrolling == other.reverse_scrolling
202    }
203}
204
205impl Eq for ScrollElement {}
206
207impl Hash for ScrollElement {
208    fn hash<H: Hasher>(&self, state: &mut H) {
209        (Rc::as_ptr(&self.state.inner) as usize).hash(state);
210        self.is_vertical.hash(state);
211        self.reverse_scrolling.hash(state);
212    }
213}
214
215impl ModifierNodeElement for ScrollElement {
216    type Node = ScrollNode;
217
218    fn create(&self) -> Self::Node {
219        // println!("ScrollElement::create");
220        ScrollNode::new(self.state.clone(), self.is_vertical, self.reverse_scrolling)
221    }
222
223    fn key(&self) -> Option<u64> {
224        let mut hasher = DefaultHasher::new();
225        self.state.id().hash(&mut hasher);
226        self.reverse_scrolling.hash(&mut hasher);
227        self.is_vertical.hash(&mut hasher);
228        Some(hasher.finish())
229    }
230
231    fn update(&self, node: &mut Self::Node) {
232        let needs_invalidation = !Rc::ptr_eq(&node.state.inner, &self.state.inner)
233            || node.is_vertical != self.is_vertical
234            || node.reverse_scrolling != self.reverse_scrolling;
235
236        if needs_invalidation {
237            node.state = self.state.clone();
238            node.is_vertical = self.is_vertical;
239            node.reverse_scrolling = self.reverse_scrolling;
240        }
241    }
242
243    fn capabilities(&self) -> NodeCapabilities {
244        NodeCapabilities::LAYOUT
245    }
246}
247
248/// ScrollNode layout modifier that physically moves content based on scroll position.
249/// This is the component that actually reads ScrollState and applies the visual offset.
250pub struct ScrollNode {
251    state: ScrollState,
252    is_vertical: bool,
253    reverse_scrolling: bool,
254    node_state: NodeState,
255    /// ID of the invalidation callback registered with ScrollState
256    invalidation_callback_id: Option<u64>,
257    /// We capture the NodeId when attached to ensure correct invalidation scope
258    node_id: Option<NodeId>,
259}
260
261impl ScrollNode {
262    pub fn new(state: ScrollState, is_vertical: bool, reverse_scrolling: bool) -> Self {
263        Self {
264            state,
265            is_vertical,
266            reverse_scrolling,
267            node_state: NodeState::default(),
268            invalidation_callback_id: None,
269            node_id: None,
270        }
271    }
272
273    /// Returns a reference to the ScrollState.
274    pub fn state(&self) -> &ScrollState {
275        &self.state
276    }
277}
278
279impl DelegatableNode for ScrollNode {
280    fn node_state(&self) -> &NodeState {
281        &self.node_state
282    }
283}
284
285impl ModifierNode for ScrollNode {
286    fn on_attach(&mut self, context: &mut dyn ModifierNodeContext) {
287        // Set up the invalidation callback to trigger layout when scroll state changes.
288        // We capture the node_id directly from the context, avoiding any global registry.
289
290        let node_id = context.node_id();
291        self.node_id = node_id;
292
293        if let Some(node_id) = node_id {
294            let callback_id = self.state.add_invalidate_callback(Box::new(move || {
295                // Schedule scoped layout repass for this node
296                crate::schedule_layout_repass(node_id);
297            }));
298            self.invalidation_callback_id = Some(callback_id);
299        } else {
300            log::debug!(
301                "ScrollNode attached without a NodeId; deferring invalidation registration."
302            );
303        }
304
305        // Initial invalidation
306        context.invalidate(cranpose_foundation::InvalidationKind::Layout);
307    }
308
309    fn on_detach(&mut self) {
310        // Remove invalidation callback
311        if let Some(id) = self.invalidation_callback_id.take() {
312            self.state.remove_invalidate_callback(id);
313        }
314    }
315
316    fn as_layout_node(&self) -> Option<&dyn LayoutModifierNode> {
317        Some(self)
318    }
319
320    fn as_layout_node_mut(&mut self) -> Option<&mut dyn LayoutModifierNode> {
321        Some(self)
322    }
323}
324
325impl LayoutModifierNode for ScrollNode {
326    fn measure(
327        &self,
328        _context: &mut dyn ModifierNodeContext,
329        measurable: &dyn Measurable,
330        constraints: Constraints,
331    ) -> LayoutModifierMeasureResult {
332        // Step 1: Give child infinite space in scroll direction
333        let scroll_constraints = if self.is_vertical {
334            Constraints {
335                min_height: 0.0,
336                max_height: f32::INFINITY,
337                ..constraints
338            }
339        } else {
340            Constraints {
341                min_width: 0.0,
342                max_width: f32::INFINITY,
343                ..constraints
344            }
345        };
346
347        // Step 2: Measure child
348        let placeable = measurable.measure(scroll_constraints);
349
350        // Step 3: Calculate viewport size (constrained size)
351        let width = placeable.width().min(constraints.max_width);
352        let height = placeable.height().min(constraints.max_height);
353
354        // Step 4: Calculate max scroll
355        let max_scroll = if self.is_vertical {
356            (placeable.height() - height).max(0.0)
357        } else {
358            (placeable.width() - width).max(0.0)
359        };
360
361        // Step 5: Update state with max scroll value
362        // Only update if the viewport is constrained (not infinite probe)
363        if (self.is_vertical && constraints.max_height.is_finite())
364            || (!self.is_vertical && constraints.max_width.is_finite())
365        {
366            self.state.set_max_value(max_scroll);
367        }
368
369        // Step 6: Read scroll value and calculate offset
370        // IMPORTANT: Use value_non_reactive() during measure to avoid triggering recomposition
371        let scroll = self.state.value_non_reactive().clamp(0.0, max_scroll);
372
373        let abs_scroll = if self.reverse_scrolling {
374            scroll - max_scroll
375        } else {
376            -scroll
377        };
378
379        let (x_offset, y_offset) = if self.is_vertical {
380            (0.0, abs_scroll)
381        } else {
382            (abs_scroll, 0.0)
383        };
384
385        // Step 7: Return result with viewport size and scroll offset as placement_offset
386        // This makes the scroll offset part of the layout modifier's placement, which will be
387        // correctly applied to children by the layout system
388        LayoutModifierMeasureResult::new(Size { width, height }, x_offset, y_offset)
389    }
390
391    fn min_intrinsic_width(&self, measurable: &dyn Measurable, height: f32) -> f32 {
392        measurable.min_intrinsic_width(height)
393    }
394
395    fn max_intrinsic_width(&self, measurable: &dyn Measurable, height: f32) -> f32 {
396        measurable.max_intrinsic_width(height)
397    }
398
399    fn min_intrinsic_height(&self, measurable: &dyn Measurable, width: f32) -> f32 {
400        measurable.min_intrinsic_height(width)
401    }
402
403    fn max_intrinsic_height(&self, measurable: &dyn Measurable, width: f32) -> f32 {
404        measurable.max_intrinsic_height(width)
405    }
406
407    fn create_measurement_proxy(&self) -> Option<Box<dyn cranpose_foundation::MeasurementProxy>> {
408        None
409    }
410}
411
412/// Creates a remembered ScrollState.
413///
414/// This is a convenience function for use in composable functions.
415#[macro_export]
416macro_rules! rememberScrollState {
417    ($initial:expr) => {
418        cranpose_core::remember(|| $crate::scroll::ScrollState::new($initial))
419            .with(|state| state.clone())
420    };
421    () => {
422        rememberScrollState!(0.0)
423    };
424}