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