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