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