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 std::{
12    cell::{Cell, RefCell},
13    collections::HashMap,
14    hash::{DefaultHasher, Hash, Hasher},
15    rc::{Rc, Weak},
16};
17
18use cranpose_core::{MutableState, NodeId};
19use cranpose_foundation::{
20    Constraints, DelegatableNode, LayoutModifierNode, Measurable, ModifierNode,
21    ModifierNodeContext, ModifierNodeElement, NodeCapabilities, NodeState,
22};
23use cranpose_ui_graphics::Size;
24use cranpose_ui_layout::LayoutModifierMeasureResult;
25
26/// State object for scroll position tracking.
27///
28/// Holds the current scroll offset and provides methods to programmatically
29/// control scrolling. Can be created with `rememberScrollState()`.
30///
31/// This is a pure scroll model - it does NOT store ephemeral gesture/pointer state.
32/// Gesture state is managed locally in the scroll modifier.
33#[derive(Clone, Copy)]
34pub struct ScrollState {
35    value: MutableState<f32>,
36    inner: MutableState<Rc<ScrollStateInner>>,
37}
38
39pub(crate) struct ScrollStateInner {
40    max_value: RefCell<f32>,
41    /// How much of the content the last measure pass put on screen. Needed by
42    /// anything that reports the scroll position — a scrollbar's thumb is the
43    /// share of the content visible, which `max_value` alone cannot say.
44    viewport_extent: RefCell<f32>,
45    invalidate_callbacks: RefCell<HashMap<u64, Rc<dyn Fn()>>>,
46    next_invalidate_callback_id: Cell<u64>,
47    pending_invalidation: Cell<bool>,
48    settle_policy: RefCell<Option<ScrollSettlePolicy>>,
49}
50
51/// A scroll position and the extents it is measured against, read together.
52#[derive(Clone, Copy, Debug, PartialEq)]
53pub struct ScrollMetrics {
54    /// How far the content has travelled, in logical pixels.
55    pub offset: f32,
56    /// The furthest it can travel. Zero when the content fits.
57    pub max_offset: f32,
58    /// How much of the content is on screen.
59    pub viewport_extent: f32,
60}
61
62impl ScrollMetrics {
63    /// The whole scrollable content along the main axis.
64    pub fn content_extent(self) -> f32 {
65        self.viewport_extent + self.max_offset
66    }
67
68    /// How far through the scroll the content is, in `0..=1`.
69    pub fn progress(self) -> f32 {
70        if self.max_offset <= 0.0 {
71            0.0
72        } else {
73            (self.offset / self.max_offset).clamp(0.0, 1.0)
74        }
75    }
76
77    /// The thumb an indicator of this scroll should draw, given how short its
78    /// thumb may get, or `None` when the content fits.
79    pub fn thumb(
80        self,
81        bounds: crate::scrollbar::ThumbBounds,
82    ) -> Option<crate::scrollbar::ThumbGeometry> {
83        crate::scrollbar::thumb_geometry(
84            self.content_extent(),
85            self.viewport_extent,
86            self.offset,
87            bounds,
88        )
89    }
90}
91
92/// Remaps where a scroll comes to rest once the user's interaction ends — the
93/// `UIScrollView targetContentOffset` analog. Receives the naturally proposed
94/// rest offset (the fling's predicted end, or the current offset for a plain
95/// release/wheel idle) and the release velocity in offset units/sec; returns
96/// the offset the scroll should settle at. Used e.g. by the liquid nav bar to
97/// snap out of the large-title collapse band so the title never rests
98/// half-faded.
99pub type ScrollSettlePolicy = Rc<dyn Fn(f32, f32) -> f32>;
100
101/// `UIScrollView`'s rubber-band constant. Matches the published WebKit/UIKit
102/// value (0.55) and was independently reproduced by least-squares fit
103/// against a drag-past-the-top-edge trace recorded on the iOS 26.5 Simulator
104/// (fit c=0.5499, residual <=0.18pt) — see `ios_fling_measurement.rs`.
105const RUBBER_BAND_COEFFICIENT: f32 = 0.55;
106
107/// Maps a raw (unresisted) pull `x` past the edge to the on-screen overscroll
108/// offset via `UIScrollView`'s rubber-band curve, `f(x) = x*d*c/(d+c*x)`,
109/// where `d` is the scrollable viewport's main-axis extent and `c` is
110/// [`RUBBER_BAND_COEFFICIENT`]. This asymptotically approaches `d` as `x`
111/// grows rather than hard-clamping, matching the measured curve exactly
112/// (unlike a linear-resistance-then-clamp model).
113fn rubber_band(raw: f32, dimension: f32) -> f32 {
114    if !dimension.is_finite() || dimension <= 0.0 {
115        return raw;
116    }
117    let x = raw.abs();
118    let c = RUBBER_BAND_COEFFICIENT;
119    (x * dimension * c / (dimension + c * x)).copysign(raw)
120}
121
122/// Inverse of [`rubber_band`]: recovers the raw pull that produced a given
123/// on-screen `visible` offset. Used to keep the raw accumulator consistent
124/// whenever something other than [`OverscrollEffect::apply_drag_delta`]
125/// writes the visible offset directly (the settle/bounce-back animation), so
126/// a drag resuming right after an interrupted settle composes smoothly
127/// instead of jumping.
128fn rubber_band_inverse(visible: f32, dimension: f32) -> f32 {
129    if !dimension.is_finite() || dimension <= 0.0 {
130        return visible;
131    }
132    let c = RUBBER_BAND_COEFFICIENT;
133    let v = visible.abs().min(dimension * 0.999_9);
134    (v * dimension / (c * (dimension - v))).copysign(visible)
135}
136
137#[derive(Clone)]
138pub(crate) struct OverscrollEffect {
139    inner: Rc<OverscrollEffectInner>,
140}
141
142struct OverscrollEffectInner {
143    /// Cumulative raw (unresisted) pull past the edge; the single source of
144    /// truth `visible` is derived from via [`rubber_band`].
145    raw: Cell<f32>,
146    /// What's actually rendered as the overscroll translation.
147    visible: Cell<f32>,
148    /// The scrollable viewport's main-axis extent, i.e. `d` in the
149    /// rubber-band formula.
150    dimension: Cell<f32>,
151    invalidate_callbacks: RefCell<HashMap<u64, Rc<dyn Fn()>>>,
152    next_callback_id: Cell<u64>,
153}
154
155impl OverscrollEffect {
156    pub(crate) fn new() -> Self {
157        Self {
158            inner: Rc::new(OverscrollEffectInner {
159                raw: Cell::new(0.0),
160                visible: Cell::new(0.0),
161                dimension: Cell::new(0.0),
162                invalidate_callbacks: RefCell::new(HashMap::new()),
163                next_callback_id: Cell::new(1),
164            }),
165        }
166    }
167
168    pub(crate) fn offset(&self) -> f32 {
169        self.inner.visible.get()
170    }
171
172    pub(crate) fn set_dimension(&self, dimension: f32) {
173        if !dimension.is_finite() || dimension <= 0.0 {
174            return;
175        }
176        self.inner.dimension.set(dimension);
177        self.set_visible(rubber_band(self.inner.raw.get(), dimension));
178    }
179
180    pub(crate) fn apply_drag_delta(&self, delta: f32) -> f32 {
181        if !delta.is_finite() || delta.abs() <= f32::EPSILON {
182            return 0.0;
183        }
184        let before = self.offset();
185        let raw = self.inner.raw.get() + delta;
186        self.inner.raw.set(raw);
187        self.set_visible(rubber_band(raw, self.inner.dimension.get()));
188        self.offset() - before
189    }
190
191    pub(crate) fn apply_settle_delta(&self, delta: f32) -> f32 {
192        let offset = self.offset();
193        if offset.abs() <= f32::EPSILON {
194            return 0.0;
195        }
196        let proposed = offset + delta;
197        let crosses_edge = proposed.signum() != offset.signum();
198        let next = if crosses_edge { 0.0 } else { proposed };
199        let applied = next - offset;
200        self.inner
201            .raw
202            .set(rubber_band_inverse(next, self.inner.dimension.get()));
203        self.set_visible(next);
204        applied
205    }
206
207    pub(crate) fn apply_to_scroll<F>(&self, delta: f32, perform_scroll: F) -> f32
208    where
209        F: FnOnce(f32) -> f32,
210    {
211        if !delta.is_finite() || delta.abs() <= f32::EPSILON {
212            return 0.0;
213        }
214        let mut remaining = delta;
215        let mut consumed = 0.0;
216        let offset = self.offset();
217        if offset.abs() > f32::EPSILON && delta.signum() != offset.signum() {
218            let release = delta.abs().min(offset.abs()) * delta.signum();
219            let released = self.apply_settle_delta(release);
220            consumed += released;
221            remaining -= released;
222        }
223        if remaining.abs() > f32::EPSILON {
224            let target_consumed = perform_scroll(remaining);
225            consumed += target_consumed;
226            let unconsumed = remaining - target_consumed;
227            if unconsumed.abs() > f32::EPSILON {
228                consumed += self.apply_drag_delta(unconsumed);
229            }
230        }
231        consumed
232    }
233
234    pub(crate) fn apply_to_fling<F>(&self, delta: f32, perform_scroll: F) -> f32
235    where
236        F: FnOnce(f32) -> f32,
237    {
238        let consumed = perform_scroll(delta);
239        let unconsumed = delta - consumed;
240        if unconsumed.abs() > f32::EPSILON {
241            self.apply_drag_delta(-unconsumed);
242        }
243        consumed
244    }
245
246    pub(crate) fn add_invalidate_callback(&self, callback: Box<dyn Fn()>) -> u64 {
247        let id = self.inner.next_callback_id.get();
248        self.inner.next_callback_id.set(id.saturating_add(1));
249        self.inner
250            .invalidate_callbacks
251            .borrow_mut()
252            .insert(id, Rc::from(callback));
253        id
254    }
255
256    pub(crate) fn remove_invalidate_callback(&self, id: u64) {
257        self.inner.invalidate_callbacks.borrow_mut().remove(&id);
258    }
259
260    pub(crate) fn ptr_eq(&self, other: &Self) -> bool {
261        Rc::ptr_eq(&self.inner, &other.inner)
262    }
263
264    fn set_visible(&self, visible: f32) {
265        if (visible - self.offset()).abs() <= f32::EPSILON {
266            return;
267        }
268        self.inner.visible.set(visible);
269        let callbacks = self
270            .inner
271            .invalidate_callbacks
272            .borrow()
273            .values()
274            .cloned()
275            .collect::<Vec<_>>();
276        for callback in callbacks {
277            callback();
278        }
279    }
280}
281
282impl PartialEq for ScrollState {
283    /// Two handles are equal when they share the same underlying state
284    /// (identity, not value — composable-skip semantics).
285    fn eq(&self, other: &Self) -> bool {
286        self.inner == other.inner
287    }
288}
289
290impl ScrollState {
291    /// Creates a new ScrollState with the given initial scroll position.
292    pub fn new(initial: f32) -> Self {
293        let runtime = cranpose_core::current_runtime_handle()
294            .expect("ScrollState::new requires an active runtime");
295        Self {
296            value: MutableState::with_runtime(initial, runtime.clone()),
297            inner: MutableState::with_runtime(
298                Rc::new(ScrollStateInner {
299                    max_value: RefCell::new(0.0),
300                    viewport_extent: RefCell::new(0.0),
301                    invalidate_callbacks: RefCell::new(HashMap::new()),
302                    next_invalidate_callback_id: Cell::new(1),
303                    pending_invalidation: Cell::new(false),
304                    settle_policy: RefCell::new(None),
305                }),
306                runtime,
307            ),
308        }
309    }
310
311    fn inner(&self) -> Rc<ScrollStateInner> {
312        self.inner.get_non_reactive()
313    }
314
315    /// Installs (or clears) the settle policy consulted when interactions end.
316    pub fn set_settle_policy(&self, policy: Option<ScrollSettlePolicy>) {
317        *self.inner().settle_policy.borrow_mut() = policy;
318    }
319
320    /// The currently installed settle policy, if any.
321    pub fn settle_policy(&self) -> Option<ScrollSettlePolicy> {
322        self.inner().settle_policy.borrow().clone()
323    }
324
325    /// Get the unique ID of this ScrollState
326    pub fn id(&self) -> u64 {
327        let mut hasher = DefaultHasher::new();
328        self.inner.runtime_state_id().hash(&mut hasher);
329        hasher.finish()
330    }
331
332    /// Gets the current scroll position in pixels (reactive - triggers recomposition).
333    ///
334    /// Use this in Composable functions when you want UI to update on scroll.
335    /// Example: `Text("Scroll position: ${scrollState.value()}")`
336    pub fn value(&self) -> f32 {
337        self.value.value()
338    }
339
340    /// Gets the current scroll position in pixels (non-reactive).
341    ///
342    /// Use this in layout/measure phase to avoid triggering recomposition.
343    /// This is called internally by ScrollNode::measure().
344    pub fn value_non_reactive(&self) -> f32 {
345        self.value.get_non_reactive()
346    }
347
348    /// Gets the maximum scroll value.
349    pub fn max_value(&self) -> f32 {
350        *self.inner().max_value.borrow()
351    }
352
353    /// Scrolls by the given delta, clamping to valid range [0, max_value].
354    /// Returns the actual amount scrolled.
355    pub fn dispatch_raw_delta(&self, delta: f32) -> f32 {
356        let current = self.value_non_reactive();
357        let max = self.max_value();
358        let new_value = (current + delta).clamp(0.0, max);
359        let actual_delta = new_value - current;
360
361        if actual_delta.abs() > 0.001 {
362            // Use MutableState::set which triggers snapshot observers for reactive updates
363            self.value.set(new_value);
364
365            self.invalidate();
366        }
367
368        actual_delta
369    }
370
371    /// The main-axis extent of the scroll viewport, as the last measure pass
372    /// resolved it.
373    pub fn viewport_extent(&self) -> f32 {
374        *self.inner().viewport_extent.borrow()
375    }
376
377    /// The whole scrollable content along the main axis.
378    pub fn content_extent(&self) -> f32 {
379        self.viewport_extent() + self.max_value()
380    }
381
382    /// Everything an indicator needs about this scroll, read together so it
383    /// cannot mix a position from one frame with an extent from another.
384    ///
385    /// Read outside composition — during draw, or from a gesture — so following
386    /// a scroll costs no recomposition.
387    pub fn metrics(&self) -> ScrollMetrics {
388        let inner = self.inner();
389        let max_offset = *inner.max_value.borrow();
390        let viewport_extent = *inner.viewport_extent.borrow();
391        ScrollMetrics {
392            offset: self.value_non_reactive(),
393            max_offset,
394            viewport_extent,
395        }
396    }
397
398    /// Sets the maximum scroll value (internal use by ScrollNode).
399    pub(crate) fn set_max_value(&self, max: f32) {
400        *self.inner().max_value.borrow_mut() = max;
401    }
402
403    /// Records the measured viewport extent (internal use by ScrollNode).
404    pub(crate) fn set_viewport_extent(&self, extent: f32) {
405        *self.inner().viewport_extent.borrow_mut() = extent;
406    }
407
408    /// Scrolls to the given position immediately.
409    pub fn scroll_to(&self, position: f32) {
410        let max = self.max_value();
411        let clamped = position.clamp(0.0, max);
412
413        self.value.set(clamped);
414
415        self.invalidate();
416    }
417
418    /// Adds an invalidation callback and returns its ID
419    pub(crate) fn add_invalidate_callback(&self, callback: Box<dyn Fn()>) -> u64 {
420        let inner = self.inner();
421        let id = inner.next_invalidate_callback_id.get();
422        inner.next_invalidate_callback_id.set(id.saturating_add(1));
423        let callback: Rc<dyn Fn()> = Rc::from(callback);
424        inner
425            .invalidate_callbacks
426            .borrow_mut()
427            .insert(id, Rc::clone(&callback));
428        if inner.pending_invalidation.replace(false) {
429            callback();
430        }
431        id
432    }
433
434    /// Removes an invalidation callback by ID
435    pub(crate) fn remove_invalidate_callback(&self, id: u64) {
436        self.inner().invalidate_callbacks.borrow_mut().remove(&id);
437    }
438
439    fn invalidate(&self) {
440        let inner = self.inner();
441        let callbacks: Vec<Rc<dyn Fn()>> = {
442            let callbacks = inner.invalidate_callbacks.borrow();
443            if callbacks.is_empty() {
444                inner.pending_invalidation.set(true);
445                return;
446            }
447            callbacks.values().cloned().collect()
448        };
449        for callback in callbacks {
450            callback();
451        }
452    }
453}
454
455#[derive(Clone)]
456pub(crate) struct ScrollMotionContext {
457    inner: Rc<ScrollMotionContextInner>,
458}
459
460#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
461pub(crate) enum ScrollMotionContextKey {
462    ScrollState {
463        state_id: u64,
464        is_vertical: bool,
465        reverse_scrolling: bool,
466    },
467    LazyList {
468        state_identity: usize,
469        is_vertical: bool,
470        reverse_scrolling: bool,
471    },
472    /// A control dragged directly rather than a scrolling container. It has no
473    /// edge to overscroll, but it shares the motion context so the renderer
474    /// sees the same "a gesture is in flight" signal it does for a scroll.
475    Draggable {
476        state_identity: usize,
477        is_vertical: bool,
478    },
479}
480
481struct ScrollMotionContextInner {
482    active: Cell<bool>,
483    transient_active: Cell<bool>,
484    generation: Cell<u64>,
485    invalidate_callbacks: RefCell<HashMap<u64, Rc<dyn Fn()>>>,
486    next_invalidate_callback_id: Cell<u64>,
487    pending_invalidation: Cell<bool>,
488    overscroll: OverscrollEffect,
489}
490
491pub(crate) struct ScrollMotionContextStore {
492    contexts: RefCell<HashMap<ScrollMotionContextKey, Weak<ScrollMotionContextInner>>>,
493}
494
495impl ScrollMotionContextStore {
496    pub(crate) fn new() -> Self {
497        Self {
498            contexts: RefCell::new(HashMap::new()),
499        }
500    }
501
502    fn context_for_key(&self, key: ScrollMotionContextKey) -> ScrollMotionContext {
503        let mut contexts = self.contexts.borrow_mut();
504        if let Some(inner) = contexts.get(&key).and_then(Weak::upgrade) {
505            return ScrollMotionContext { inner };
506        }
507
508        let context = ScrollMotionContext::new();
509        contexts.insert(key, Rc::downgrade(&context.inner));
510        contexts.retain(|_, weak| weak.strong_count() > 0);
511        context
512    }
513
514    pub(crate) fn clear_transient_after_frame(&self) {
515        let contexts = {
516            let mut contexts = self.contexts.borrow_mut();
517            let live = contexts
518                .values()
519                .filter_map(Weak::upgrade)
520                .collect::<Vec<_>>();
521            contexts.retain(|_, weak| weak.strong_count() > 0);
522            live
523        };
524        for inner in contexts {
525            ScrollMotionContext { inner }.clear_transient_after_frame();
526        }
527    }
528}
529
530pub(crate) fn scroll_motion_context_for_key(key: ScrollMotionContextKey) -> ScrollMotionContext {
531    crate::render_state::with_scroll_motion_context_store(|store| store.context_for_key(key))
532}
533
534impl ScrollMotionContext {
535    pub(crate) fn new() -> Self {
536        Self {
537            inner: Rc::new(ScrollMotionContextInner {
538                active: Cell::new(false),
539                transient_active: Cell::new(false),
540                generation: Cell::new(0),
541                invalidate_callbacks: RefCell::new(HashMap::new()),
542                next_invalidate_callback_id: Cell::new(1),
543                pending_invalidation: Cell::new(false),
544                overscroll: OverscrollEffect::new(),
545            }),
546        }
547    }
548
549    pub(crate) fn is_active(&self) -> bool {
550        self.inner.active.get() || self.inner.transient_active.get()
551    }
552
553    pub(crate) fn ptr_eq(&self, other: &Self) -> bool {
554        Rc::ptr_eq(&self.inner, &other.inner)
555    }
556
557    pub(crate) fn stable_key(&self) -> usize {
558        Rc::as_ptr(&self.inner) as usize
559    }
560
561    pub(crate) fn overscroll(&self) -> OverscrollEffect {
562        self.inner.overscroll.clone()
563    }
564
565    pub(crate) fn set_active(&self, active: bool) {
566        let was_active = self.is_active();
567        self.inner.active.set(active);
568        if !active {
569            self.inner.transient_active.set(false);
570        }
571        if was_active != self.is_active() {
572            self.bump_generation();
573            self.invalidate();
574        }
575    }
576
577    pub(crate) fn activate_for_current_frame(&self) {
578        let was_active = self.is_active();
579        self.inner.transient_active.set(true);
580        self.bump_generation();
581        if !was_active {
582            self.invalidate();
583        }
584    }
585
586    pub(crate) fn add_invalidate_callback(&self, callback: Box<dyn Fn()>) -> u64 {
587        let id = self.inner.next_invalidate_callback_id.get();
588        self.inner
589            .next_invalidate_callback_id
590            .set(id.saturating_add(1));
591        let callback: Rc<dyn Fn()> = Rc::from(callback);
592        self.inner
593            .invalidate_callbacks
594            .borrow_mut()
595            .insert(id, Rc::clone(&callback));
596        if self.inner.pending_invalidation.replace(false) {
597            callback();
598        }
599        id
600    }
601
602    pub(crate) fn remove_invalidate_callback(&self, id: u64) {
603        self.inner.invalidate_callbacks.borrow_mut().remove(&id);
604    }
605
606    fn bump_generation(&self) -> u64 {
607        let next = self.inner.generation.get().wrapping_add(1);
608        self.inner.generation.set(next);
609        next
610    }
611
612    fn clear_transient_after_frame(&self) {
613        let was_active = self.is_active();
614        if self.inner.transient_active.replace(false) {
615            self.bump_generation();
616            if was_active != self.is_active() {
617                self.invalidate();
618            }
619        }
620    }
621
622    fn invalidate(&self) {
623        let callbacks: Vec<Rc<dyn Fn()>> = {
624            let callbacks = self.inner.invalidate_callbacks.borrow();
625            if callbacks.is_empty() {
626                self.inner.pending_invalidation.set(true);
627                return;
628            }
629            callbacks.values().cloned().collect()
630        };
631        for callback in callbacks {
632            callback();
633        }
634    }
635}
636
637/// Element for creating a ScrollNode.
638#[derive(Clone)]
639pub struct ScrollElement {
640    state: ScrollState,
641    overscroll: OverscrollEffect,
642    is_vertical: bool,
643    reverse_scrolling: bool,
644}
645
646impl ScrollElement {
647    pub(crate) fn new(
648        state: ScrollState,
649        overscroll: OverscrollEffect,
650        is_vertical: bool,
651        reverse_scrolling: bool,
652    ) -> Self {
653        Self {
654            state,
655            overscroll,
656            is_vertical,
657            reverse_scrolling,
658        }
659    }
660}
661
662impl std::fmt::Debug for ScrollElement {
663    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
664        f.debug_struct("ScrollElement")
665            .field("is_vertical", &self.is_vertical)
666            .field("reverse_scrolling", &self.reverse_scrolling)
667            .finish()
668    }
669}
670
671impl PartialEq for ScrollElement {
672    fn eq(&self, other: &Self) -> bool {
673        // ScrollStates are equal if they point to the same underlying state
674        self.state == other.state
675            && self.is_vertical == other.is_vertical
676            && self.reverse_scrolling == other.reverse_scrolling
677    }
678}
679
680impl Eq for ScrollElement {}
681
682impl Hash for ScrollElement {
683    fn hash<H: Hasher>(&self, state: &mut H) {
684        self.state.inner.runtime_state_id().hash(state);
685        self.is_vertical.hash(state);
686        self.reverse_scrolling.hash(state);
687    }
688}
689
690impl ModifierNodeElement for ScrollElement {
691    type Node = ScrollNode;
692
693    fn create(&self) -> Self::Node {
694        // println!("ScrollElement::create");
695        ScrollNode::new(
696            self.state,
697            self.overscroll.clone(),
698            self.is_vertical,
699            self.reverse_scrolling,
700        )
701    }
702
703    fn key(&self) -> Option<u64> {
704        let mut hasher = DefaultHasher::new();
705        self.state.id().hash(&mut hasher);
706        self.reverse_scrolling.hash(&mut hasher);
707        self.is_vertical.hash(&mut hasher);
708        Some(hasher.finish())
709    }
710
711    fn update(&self, node: &mut Self::Node) {
712        let needs_invalidation = node.state != self.state
713            || node.is_vertical != self.is_vertical
714            || node.reverse_scrolling != self.reverse_scrolling
715            || !node.overscroll.ptr_eq(&self.overscroll);
716
717        if needs_invalidation {
718            node.state = self.state;
719            node.is_vertical = self.is_vertical;
720            node.reverse_scrolling = self.reverse_scrolling;
721            node.overscroll = self.overscroll.clone();
722        }
723    }
724
725    fn capabilities(&self) -> NodeCapabilities {
726        NodeCapabilities::LAYOUT
727    }
728}
729
730/// ScrollNode layout modifier that physically moves content based on scroll position.
731/// This is the component that actually reads ScrollState and applies the visual offset.
732pub struct ScrollNode {
733    state: ScrollState,
734    overscroll: OverscrollEffect,
735    is_vertical: bool,
736    reverse_scrolling: bool,
737    node_state: NodeState,
738    /// ID of the invalidation callback registered with ScrollState
739    invalidation_callback_id: Option<u64>,
740    overscroll_callback_id: Option<u64>,
741    /// We capture the NodeId when attached to ensure correct invalidation scope
742    node_id: Option<NodeId>,
743}
744
745impl ScrollNode {
746    pub(crate) fn new(
747        state: ScrollState,
748        overscroll: OverscrollEffect,
749        is_vertical: bool,
750        reverse_scrolling: bool,
751    ) -> Self {
752        Self {
753            state,
754            overscroll,
755            is_vertical,
756            reverse_scrolling,
757            node_state: NodeState::default(),
758            invalidation_callback_id: None,
759            overscroll_callback_id: None,
760            node_id: None,
761        }
762    }
763
764    /// Returns a reference to the ScrollState.
765    pub fn state(&self) -> &ScrollState {
766        &self.state
767    }
768}
769
770impl DelegatableNode for ScrollNode {
771    fn node_state(&self) -> &NodeState {
772        &self.node_state
773    }
774}
775
776impl ModifierNode for ScrollNode {
777    fn on_attach(&mut self, context: &mut dyn ModifierNodeContext) {
778        // Set up the invalidation callback to trigger layout when scroll state changes.
779        // We capture the node_id directly from the context, avoiding any global registry.
780
781        let node_id = context.node_id();
782        self.node_id = node_id;
783
784        if let Some(node_id) = node_id {
785            let callback_id = self.state.add_invalidate_callback(Box::new(move || {
786                // Schedule scoped layout repass for this node
787                crate::schedule_layout_repass(node_id);
788            }));
789            self.invalidation_callback_id = Some(callback_id);
790            let callback_id = self.overscroll.add_invalidate_callback(Box::new(move || {
791                crate::schedule_layout_repass(node_id);
792            }));
793            self.overscroll_callback_id = Some(callback_id);
794        } else {
795            log::debug!(
796                "ScrollNode attached without a NodeId; deferring invalidation registration."
797            );
798        }
799
800        // Initial invalidation
801        context.invalidate(cranpose_foundation::InvalidationKind::Layout);
802    }
803
804    fn on_detach(&mut self) {
805        // Remove invalidation callback
806        if let Some(id) = self.invalidation_callback_id.take() {
807            self.state.remove_invalidate_callback(id);
808        }
809        if let Some(id) = self.overscroll_callback_id.take() {
810            self.overscroll.remove_invalidate_callback(id);
811        }
812    }
813
814    fn as_layout_node(&self) -> Option<&dyn LayoutModifierNode> {
815        Some(self)
816    }
817
818    fn as_layout_node_mut(&mut self) -> Option<&mut dyn LayoutModifierNode> {
819        Some(self)
820    }
821}
822
823impl LayoutModifierNode for ScrollNode {
824    fn measure(
825        &self,
826        _context: &mut dyn ModifierNodeContext,
827        measurable: &dyn Measurable,
828        constraints: Constraints,
829    ) -> LayoutModifierMeasureResult {
830        // Step 1: Give child infinite space in scroll direction
831        let scroll_constraints = if self.is_vertical {
832            Constraints {
833                min_height: 0.0,
834                max_height: f32::INFINITY,
835                ..constraints
836            }
837        } else {
838            Constraints {
839                min_width: 0.0,
840                max_width: f32::INFINITY,
841                ..constraints
842            }
843        };
844
845        // Step 2: Measure child
846        let placeable = measurable.measure(scroll_constraints);
847
848        // Step 3: Calculate viewport size (constrained size)
849        let width = placeable.width().min(constraints.max_width);
850        let height = placeable.height().min(constraints.max_height);
851
852        // Step 4: Calculate max scroll
853        let max_scroll = if self.is_vertical {
854            (placeable.height() - height).max(0.0)
855        } else {
856            (placeable.width() - width).max(0.0)
857        };
858
859        // Step 5: Update state with max scroll value
860        // Only update if the viewport is constrained (not infinite probe)
861        if (self.is_vertical && constraints.max_height.is_finite())
862            || (!self.is_vertical && constraints.max_width.is_finite())
863        {
864            self.state.set_max_value(max_scroll);
865            self.state
866                .set_viewport_extent(if self.is_vertical { height } else { width });
867            self.overscroll
868                .set_dimension(if self.is_vertical { height } else { width });
869        }
870
871        // Step 6: Read scroll value and calculate offset
872        // IMPORTANT: Use value_non_reactive() during measure to avoid triggering recomposition
873        let scroll = self.state.value_non_reactive().clamp(0.0, max_scroll);
874
875        let abs_scroll = if self.reverse_scrolling {
876            scroll - max_scroll
877        } else {
878            -scroll
879        };
880        let abs_scroll = abs_scroll + self.overscroll.offset();
881
882        let (x_offset, y_offset) = if self.is_vertical {
883            (0.0, abs_scroll)
884        } else {
885            (abs_scroll, 0.0)
886        };
887
888        // Step 7: Return result with viewport size and scroll offset as placement_offset
889        // This makes the scroll offset part of the layout modifier's placement, which will be
890        // correctly applied to children by the layout system
891        LayoutModifierMeasureResult::new(Size { width, height }, x_offset, y_offset)
892    }
893
894    fn min_intrinsic_width(&self, measurable: &dyn Measurable, height: f32) -> f32 {
895        measurable.min_intrinsic_width(height)
896    }
897
898    fn max_intrinsic_width(&self, measurable: &dyn Measurable, height: f32) -> f32 {
899        measurable.max_intrinsic_width(height)
900    }
901
902    fn min_intrinsic_height(&self, measurable: &dyn Measurable, width: f32) -> f32 {
903        measurable.min_intrinsic_height(width)
904    }
905
906    fn max_intrinsic_height(&self, measurable: &dyn Measurable, width: f32) -> f32 {
907        measurable.max_intrinsic_height(width)
908    }
909}
910
911/// Creates a remembered ScrollState.
912///
913/// This is a convenience function for use in composable functions.
914#[macro_export]
915macro_rules! rememberScrollState {
916    ($initial:expr) => {
917        cranpose_core::remember(|| $crate::scroll::ScrollState::new($initial))
918            .with(|state| state.clone())
919    };
920    () => {
921        rememberScrollState!(0.0)
922    };
923}
924
925#[cfg(test)]
926#[path = "tests/scroll_tests.rs"]
927mod tests;