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::{ownedMutableStateOf, NodeId, OwnedMutableState};
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::collections::HashMap;
20use std::hash::{DefaultHasher, Hash, Hasher};
21use std::rc::{Rc, Weak};
22
23/// State object for scroll position tracking.
24///
25/// Holds the current scroll offset and provides methods to programmatically
26/// control scrolling. Can be created with `rememberScrollState()`.
27///
28/// This is a pure scroll model - it does NOT store ephemeral gesture/pointer state.
29/// Gesture state is managed locally in the scroll modifier.
30#[derive(Clone)]
31pub struct ScrollState {
32    inner: Rc<ScrollStateInner>,
33}
34
35pub(crate) struct ScrollStateInner {
36    /// Current scroll offset in pixels.
37    /// Uses MutableState<f32> for reactivity - Composables can observe this value.
38    /// Layout reads use get_non_reactive() to avoid triggering recomposition.
39    value: OwnedMutableState<f32>,
40    /// Maximum scroll value (content_size - viewport_size)
41    /// Using RefCell instead of MutableState to avoid snapshot isolation issues
42    max_value: RefCell<f32>,
43    /// Callbacks to invalidate layout when scroll value changes
44    /// Using HashMap to allow multiple listeners (e.g. real node + clones)
45    invalidate_callbacks: RefCell<HashMap<u64, Rc<dyn Fn()>>>,
46    next_invalidate_callback_id: Cell<u64>,
47    /// Tracks whether we need to invalidate once a callback is registered.
48    pending_invalidation: Cell<bool>,
49    /// Optional settle policy consulted when a gesture/fling/wheel interaction
50    /// ends (see [`ScrollSettlePolicy`]).
51    settle_policy: RefCell<Option<ScrollSettlePolicy>>,
52}
53
54/// Remaps where a scroll comes to rest once the user's interaction ends — the
55/// `UIScrollView targetContentOffset` analog. Receives the naturally proposed
56/// rest offset (the fling's predicted end, or the current offset for a plain
57/// release/wheel idle) and the release velocity in offset units/sec; returns
58/// the offset the scroll should settle at. Used e.g. by the liquid nav bar to
59/// snap out of the large-title collapse band so the title never rests
60/// half-faded.
61pub type ScrollSettlePolicy = Rc<dyn Fn(f32, f32) -> f32>;
62
63impl PartialEq for ScrollState {
64    /// Two handles are equal when they share the same underlying state
65    /// (identity, not value — composable-skip semantics).
66    fn eq(&self, other: &Self) -> bool {
67        Rc::ptr_eq(&self.inner, &other.inner)
68    }
69}
70
71impl ScrollState {
72    /// Creates a new ScrollState with the given initial scroll position.
73    pub fn new(initial: f32) -> Self {
74        Self {
75            inner: Rc::new(ScrollStateInner {
76                value: ownedMutableStateOf(initial),
77                max_value: RefCell::new(0.0),
78                invalidate_callbacks: RefCell::new(HashMap::new()),
79                next_invalidate_callback_id: Cell::new(1),
80                pending_invalidation: Cell::new(false),
81                settle_policy: RefCell::new(None),
82            }),
83        }
84    }
85
86    /// Installs (or clears) the settle policy consulted when interactions end.
87    pub fn set_settle_policy(&self, policy: Option<ScrollSettlePolicy>) {
88        *self.inner.settle_policy.borrow_mut() = policy;
89    }
90
91    /// The currently installed settle policy, if any.
92    pub fn settle_policy(&self) -> Option<ScrollSettlePolicy> {
93        self.inner.settle_policy.borrow().clone()
94    }
95
96    /// Get the unique ID of this ScrollState
97    pub fn id(&self) -> u64 {
98        Rc::as_ptr(&self.inner) as usize as u64
99    }
100
101    /// Gets the current scroll position in pixels (reactive - triggers recomposition).
102    ///
103    /// Use this in Composable functions when you want UI to update on scroll.
104    /// Example: `Text("Scroll position: ${scrollState.value()}")`
105    pub fn value(&self) -> f32 {
106        self.inner.value.with(|v| *v)
107    }
108
109    /// Gets the current scroll position in pixels (non-reactive).
110    ///
111    /// Use this in layout/measure phase to avoid triggering recomposition.
112    /// This is called internally by ScrollNode::measure().
113    pub fn value_non_reactive(&self) -> f32 {
114        self.inner.value.get_non_reactive()
115    }
116
117    /// Gets the maximum scroll value.
118    pub fn max_value(&self) -> f32 {
119        *self.inner.max_value.borrow()
120    }
121
122    /// Scrolls by the given delta, clamping to valid range [0, max_value].
123    /// Returns the actual amount scrolled.
124    pub fn dispatch_raw_delta(&self, delta: f32) -> f32 {
125        let current = self.value();
126        let max = self.max_value();
127        let new_value = (current + delta).clamp(0.0, max);
128        let actual_delta = new_value - current;
129
130        if actual_delta.abs() > 0.001 {
131            // Use MutableState::set which triggers snapshot observers for reactive updates
132            self.inner.value.set(new_value);
133
134            self.invalidate();
135        }
136
137        actual_delta
138    }
139
140    /// Sets the maximum scroll value (internal use by ScrollNode).
141    pub(crate) fn set_max_value(&self, max: f32) {
142        *self.inner.max_value.borrow_mut() = max;
143    }
144
145    /// Scrolls to the given position immediately.
146    pub fn scroll_to(&self, position: f32) {
147        let max = self.max_value();
148        let clamped = position.clamp(0.0, max);
149
150        self.inner.value.set(clamped);
151
152        self.invalidate();
153    }
154
155    /// Adds an invalidation callback and returns its ID
156    pub(crate) fn add_invalidate_callback(&self, callback: Box<dyn Fn()>) -> u64 {
157        let id = self.inner.next_invalidate_callback_id.get();
158        self.inner
159            .next_invalidate_callback_id
160            .set(id.saturating_add(1));
161        let callback: Rc<dyn Fn()> = Rc::from(callback);
162        self.inner
163            .invalidate_callbacks
164            .borrow_mut()
165            .insert(id, Rc::clone(&callback));
166        if self.inner.pending_invalidation.replace(false) {
167            callback();
168        }
169        id
170    }
171
172    /// Removes an invalidation callback by ID
173    pub(crate) fn remove_invalidate_callback(&self, id: u64) {
174        self.inner.invalidate_callbacks.borrow_mut().remove(&id);
175    }
176
177    fn invalidate(&self) {
178        let callbacks: Vec<Rc<dyn Fn()>> = {
179            let callbacks = self.inner.invalidate_callbacks.borrow();
180            if callbacks.is_empty() {
181                self.inner.pending_invalidation.set(true);
182                return;
183            }
184            callbacks.values().cloned().collect()
185        };
186        for callback in callbacks {
187            callback();
188        }
189    }
190}
191
192#[derive(Clone)]
193pub(crate) struct ScrollMotionContext {
194    inner: Rc<ScrollMotionContextInner>,
195}
196
197#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
198pub(crate) enum ScrollMotionContextKey {
199    ScrollState {
200        state_id: u64,
201        is_vertical: bool,
202        reverse_scrolling: bool,
203    },
204    LazyList {
205        state_identity: usize,
206        is_vertical: bool,
207        reverse_scrolling: bool,
208    },
209}
210
211struct ScrollMotionContextInner {
212    active: Cell<bool>,
213    transient_active: Cell<bool>,
214    generation: Cell<u64>,
215    invalidate_callbacks: RefCell<HashMap<u64, Rc<dyn Fn()>>>,
216    next_invalidate_callback_id: Cell<u64>,
217    pending_invalidation: Cell<bool>,
218}
219
220pub(crate) struct ScrollMotionContextStore {
221    contexts: RefCell<HashMap<ScrollMotionContextKey, Weak<ScrollMotionContextInner>>>,
222}
223
224impl ScrollMotionContextStore {
225    pub(crate) fn new() -> Self {
226        Self {
227            contexts: RefCell::new(HashMap::new()),
228        }
229    }
230
231    fn context_for_key(&self, key: ScrollMotionContextKey) -> ScrollMotionContext {
232        let mut contexts = self.contexts.borrow_mut();
233        if let Some(inner) = contexts.get(&key).and_then(Weak::upgrade) {
234            return ScrollMotionContext { inner };
235        }
236
237        let context = ScrollMotionContext::new();
238        contexts.insert(key, Rc::downgrade(&context.inner));
239        contexts.retain(|_, weak| weak.strong_count() > 0);
240        context
241    }
242
243    pub(crate) fn clear_transient_after_frame(&self) {
244        let contexts = {
245            let mut contexts = self.contexts.borrow_mut();
246            let live = contexts
247                .values()
248                .filter_map(Weak::upgrade)
249                .collect::<Vec<_>>();
250            contexts.retain(|_, weak| weak.strong_count() > 0);
251            live
252        };
253        for inner in contexts {
254            ScrollMotionContext { inner }.clear_transient_after_frame();
255        }
256    }
257}
258
259pub(crate) fn scroll_motion_context_for_key(key: ScrollMotionContextKey) -> ScrollMotionContext {
260    crate::render_state::with_scroll_motion_context_store(|store| store.context_for_key(key))
261}
262
263impl ScrollMotionContext {
264    pub(crate) fn new() -> Self {
265        Self {
266            inner: Rc::new(ScrollMotionContextInner {
267                active: Cell::new(false),
268                transient_active: Cell::new(false),
269                generation: Cell::new(0),
270                invalidate_callbacks: RefCell::new(HashMap::new()),
271                next_invalidate_callback_id: Cell::new(1),
272                pending_invalidation: Cell::new(false),
273            }),
274        }
275    }
276
277    pub(crate) fn is_active(&self) -> bool {
278        self.inner.active.get() || self.inner.transient_active.get()
279    }
280
281    pub(crate) fn ptr_eq(&self, other: &Self) -> bool {
282        Rc::ptr_eq(&self.inner, &other.inner)
283    }
284
285    pub(crate) fn stable_key(&self) -> usize {
286        Rc::as_ptr(&self.inner) as usize
287    }
288
289    pub(crate) fn set_active(&self, active: bool) {
290        let was_active = self.is_active();
291        self.inner.active.set(active);
292        if !active {
293            self.inner.transient_active.set(false);
294        }
295        if was_active != self.is_active() {
296            self.bump_generation();
297            self.invalidate();
298        }
299    }
300
301    pub(crate) fn activate_for_current_frame(&self) {
302        let was_active = self.is_active();
303        self.inner.transient_active.set(true);
304        self.bump_generation();
305        if !was_active {
306            self.invalidate();
307        }
308    }
309
310    pub(crate) fn add_invalidate_callback(&self, callback: Box<dyn Fn()>) -> u64 {
311        let id = self.inner.next_invalidate_callback_id.get();
312        self.inner
313            .next_invalidate_callback_id
314            .set(id.saturating_add(1));
315        let callback: Rc<dyn Fn()> = Rc::from(callback);
316        self.inner
317            .invalidate_callbacks
318            .borrow_mut()
319            .insert(id, Rc::clone(&callback));
320        if self.inner.pending_invalidation.replace(false) {
321            callback();
322        }
323        id
324    }
325
326    pub(crate) fn remove_invalidate_callback(&self, id: u64) {
327        self.inner.invalidate_callbacks.borrow_mut().remove(&id);
328    }
329
330    fn bump_generation(&self) -> u64 {
331        let next = self.inner.generation.get().wrapping_add(1);
332        self.inner.generation.set(next);
333        next
334    }
335
336    fn clear_transient_after_frame(&self) {
337        let was_active = self.is_active();
338        if self.inner.transient_active.replace(false) {
339            self.bump_generation();
340            if was_active != self.is_active() {
341                self.invalidate();
342            }
343        }
344    }
345
346    fn invalidate(&self) {
347        let callbacks: Vec<Rc<dyn Fn()>> = {
348            let callbacks = self.inner.invalidate_callbacks.borrow();
349            if callbacks.is_empty() {
350                self.inner.pending_invalidation.set(true);
351                return;
352            }
353            callbacks.values().cloned().collect()
354        };
355        for callback in callbacks {
356            callback();
357        }
358    }
359}
360
361/// Element for creating a ScrollNode.
362#[derive(Clone)]
363pub struct ScrollElement {
364    state: ScrollState,
365    is_vertical: bool,
366    reverse_scrolling: bool,
367}
368
369impl ScrollElement {
370    pub fn new(state: ScrollState, is_vertical: bool, reverse_scrolling: bool) -> Self {
371        Self {
372            state,
373            is_vertical,
374            reverse_scrolling,
375        }
376    }
377}
378
379impl std::fmt::Debug for ScrollElement {
380    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
381        f.debug_struct("ScrollElement")
382            .field("is_vertical", &self.is_vertical)
383            .field("reverse_scrolling", &self.reverse_scrolling)
384            .finish()
385    }
386}
387
388impl PartialEq for ScrollElement {
389    fn eq(&self, other: &Self) -> bool {
390        // ScrollStates are equal if they point to the same underlying state
391        Rc::ptr_eq(&self.state.inner, &other.state.inner)
392            && self.is_vertical == other.is_vertical
393            && self.reverse_scrolling == other.reverse_scrolling
394    }
395}
396
397impl Eq for ScrollElement {}
398
399impl Hash for ScrollElement {
400    fn hash<H: Hasher>(&self, state: &mut H) {
401        (Rc::as_ptr(&self.state.inner) as usize).hash(state);
402        self.is_vertical.hash(state);
403        self.reverse_scrolling.hash(state);
404    }
405}
406
407impl ModifierNodeElement for ScrollElement {
408    type Node = ScrollNode;
409
410    fn create(&self) -> Self::Node {
411        // println!("ScrollElement::create");
412        ScrollNode::new(self.state.clone(), self.is_vertical, self.reverse_scrolling)
413    }
414
415    fn key(&self) -> Option<u64> {
416        let mut hasher = DefaultHasher::new();
417        self.state.id().hash(&mut hasher);
418        self.reverse_scrolling.hash(&mut hasher);
419        self.is_vertical.hash(&mut hasher);
420        Some(hasher.finish())
421    }
422
423    fn update(&self, node: &mut Self::Node) {
424        let needs_invalidation = !Rc::ptr_eq(&node.state.inner, &self.state.inner)
425            || node.is_vertical != self.is_vertical
426            || node.reverse_scrolling != self.reverse_scrolling;
427
428        if needs_invalidation {
429            node.state = self.state.clone();
430            node.is_vertical = self.is_vertical;
431            node.reverse_scrolling = self.reverse_scrolling;
432        }
433    }
434
435    fn capabilities(&self) -> NodeCapabilities {
436        NodeCapabilities::LAYOUT
437    }
438}
439
440/// ScrollNode layout modifier that physically moves content based on scroll position.
441/// This is the component that actually reads ScrollState and applies the visual offset.
442pub struct ScrollNode {
443    state: ScrollState,
444    is_vertical: bool,
445    reverse_scrolling: bool,
446    node_state: NodeState,
447    /// ID of the invalidation callback registered with ScrollState
448    invalidation_callback_id: Option<u64>,
449    /// We capture the NodeId when attached to ensure correct invalidation scope
450    node_id: Option<NodeId>,
451}
452
453impl ScrollNode {
454    pub fn new(state: ScrollState, is_vertical: bool, reverse_scrolling: bool) -> Self {
455        Self {
456            state,
457            is_vertical,
458            reverse_scrolling,
459            node_state: NodeState::default(),
460            invalidation_callback_id: None,
461            node_id: None,
462        }
463    }
464
465    /// Returns a reference to the ScrollState.
466    pub fn state(&self) -> &ScrollState {
467        &self.state
468    }
469}
470
471impl DelegatableNode for ScrollNode {
472    fn node_state(&self) -> &NodeState {
473        &self.node_state
474    }
475}
476
477impl ModifierNode for ScrollNode {
478    fn on_attach(&mut self, context: &mut dyn ModifierNodeContext) {
479        // Set up the invalidation callback to trigger layout when scroll state changes.
480        // We capture the node_id directly from the context, avoiding any global registry.
481
482        let node_id = context.node_id();
483        self.node_id = node_id;
484
485        if let Some(node_id) = node_id {
486            let callback_id = self.state.add_invalidate_callback(Box::new(move || {
487                // Schedule scoped layout repass for this node
488                crate::schedule_layout_repass(node_id);
489            }));
490            self.invalidation_callback_id = Some(callback_id);
491        } else {
492            log::debug!(
493                "ScrollNode attached without a NodeId; deferring invalidation registration."
494            );
495        }
496
497        // Initial invalidation
498        context.invalidate(cranpose_foundation::InvalidationKind::Layout);
499    }
500
501    fn on_detach(&mut self) {
502        // Remove invalidation callback
503        if let Some(id) = self.invalidation_callback_id.take() {
504            self.state.remove_invalidate_callback(id);
505        }
506    }
507
508    fn as_layout_node(&self) -> Option<&dyn LayoutModifierNode> {
509        Some(self)
510    }
511
512    fn as_layout_node_mut(&mut self) -> Option<&mut dyn LayoutModifierNode> {
513        Some(self)
514    }
515}
516
517impl LayoutModifierNode for ScrollNode {
518    fn measure(
519        &self,
520        _context: &mut dyn ModifierNodeContext,
521        measurable: &dyn Measurable,
522        constraints: Constraints,
523    ) -> LayoutModifierMeasureResult {
524        // Step 1: Give child infinite space in scroll direction
525        let scroll_constraints = if self.is_vertical {
526            Constraints {
527                min_height: 0.0,
528                max_height: f32::INFINITY,
529                ..constraints
530            }
531        } else {
532            Constraints {
533                min_width: 0.0,
534                max_width: f32::INFINITY,
535                ..constraints
536            }
537        };
538
539        // Step 2: Measure child
540        let placeable = measurable.measure(scroll_constraints);
541
542        // Step 3: Calculate viewport size (constrained size)
543        let width = placeable.width().min(constraints.max_width);
544        let height = placeable.height().min(constraints.max_height);
545
546        // Step 4: Calculate max scroll
547        let max_scroll = if self.is_vertical {
548            (placeable.height() - height).max(0.0)
549        } else {
550            (placeable.width() - width).max(0.0)
551        };
552
553        // Step 5: Update state with max scroll value
554        // Only update if the viewport is constrained (not infinite probe)
555        if (self.is_vertical && constraints.max_height.is_finite())
556            || (!self.is_vertical && constraints.max_width.is_finite())
557        {
558            self.state.set_max_value(max_scroll);
559        }
560
561        // Step 6: Read scroll value and calculate offset
562        // IMPORTANT: Use value_non_reactive() during measure to avoid triggering recomposition
563        let scroll = self.state.value_non_reactive().clamp(0.0, max_scroll);
564
565        let abs_scroll = if self.reverse_scrolling {
566            scroll - max_scroll
567        } else {
568            -scroll
569        };
570
571        let (x_offset, y_offset) = if self.is_vertical {
572            (0.0, abs_scroll)
573        } else {
574            (abs_scroll, 0.0)
575        };
576
577        // Step 7: Return result with viewport size and scroll offset as placement_offset
578        // This makes the scroll offset part of the layout modifier's placement, which will be
579        // correctly applied to children by the layout system
580        LayoutModifierMeasureResult::new(Size { width, height }, x_offset, y_offset)
581    }
582
583    fn min_intrinsic_width(&self, measurable: &dyn Measurable, height: f32) -> f32 {
584        measurable.min_intrinsic_width(height)
585    }
586
587    fn max_intrinsic_width(&self, measurable: &dyn Measurable, height: f32) -> f32 {
588        measurable.max_intrinsic_width(height)
589    }
590
591    fn min_intrinsic_height(&self, measurable: &dyn Measurable, width: f32) -> f32 {
592        measurable.min_intrinsic_height(width)
593    }
594
595    fn max_intrinsic_height(&self, measurable: &dyn Measurable, width: f32) -> f32 {
596        measurable.max_intrinsic_height(width)
597    }
598}
599
600/// Creates a remembered ScrollState.
601///
602/// This is a convenience function for use in composable functions.
603#[macro_export]
604macro_rules! rememberScrollState {
605    ($initial:expr) => {
606        cranpose_core::remember(|| $crate::scroll::ScrollState::new($initial))
607            .with(|state| state.clone())
608    };
609    () => {
610        rememberScrollState!(0.0)
611    };
612}
613
614#[cfg(test)]
615#[path = "tests/scroll_tests.rs"]
616mod tests;