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