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