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, OnceCell, 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    in_progress: MutableState<bool>,
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            in_progress: MutableState::with_runtime(false, runtime.clone()),
298            inner: MutableState::with_runtime(
299                Rc::new(ScrollStateInner {
300                    max_value: RefCell::new(0.0),
301                    viewport_extent: RefCell::new(0.0),
302                    invalidate_callbacks: InvalidationCallbacks::new(),
303                    pending_invalidation: Cell::new(false),
304                    settle_policy: RefCell::new(None),
305                }),
306                runtime,
307            ),
308        }
309    }
310
311    fn inner(&self) -> Rc<ScrollStateInner> {
312        self.inner.get_non_reactive()
313    }
314
315    /// Installs (or clears) the settle policy consulted when interactions end.
316    pub fn set_settle_policy(&self, policy: Option<ScrollSettlePolicy>) {
317        *self.inner().settle_policy.borrow_mut() = policy;
318    }
319
320    /// The currently installed settle policy, if any.
321    pub fn settle_policy(&self) -> Option<ScrollSettlePolicy> {
322        self.inner().settle_policy.borrow().clone()
323    }
324
325    /// Whether a drag, a fling or a settle moves the content now: Compose's
326    /// `isScrollInProgress`. A composable that reads it recomposes when a
327    /// scroll starts and when it ends.
328    pub fn is_scroll_in_progress(&self) -> bool {
329        self.in_progress.is_alive() && self.in_progress.get()
330    }
331
332    pub(crate) fn set_scroll_in_progress(&self, active: bool) {
333        if self.in_progress.is_alive() && self.in_progress.get_non_reactive() != active {
334            self.in_progress.set(active);
335        }
336    }
337
338    /// Get the unique ID of this ScrollState
339    pub fn id(&self) -> u64 {
340        let mut hasher = cranpose_core::hash::default::new();
341        self.inner.runtime_state_id().hash(&mut hasher);
342        hasher.finish()
343    }
344
345    /// Gets the current scroll position in pixels (reactive - triggers recomposition).
346    ///
347    /// Use this in Composable functions when you want UI to update on scroll.
348    /// Example: `Text("Scroll position: ${scrollState.value()}")`
349    pub fn value(&self) -> f32 {
350        self.value.value()
351    }
352
353    /// Gets the current scroll position in pixels (non-reactive).
354    ///
355    /// Use this in layout/measure phase to avoid triggering recomposition.
356    /// This is called internally by ScrollNode::measure().
357    pub fn value_non_reactive(&self) -> f32 {
358        self.value.get_non_reactive()
359    }
360
361    /// The scroll position, or `None` once the slot that held this state was
362    /// released. A saver reads it when the scroller leaves the composition.
363    pub fn try_value(&self) -> Option<f32> {
364        self.value.try_value()
365    }
366
367    /// Gets the maximum scroll value.
368    pub fn max_value(&self) -> f32 {
369        *self.inner().max_value.borrow()
370    }
371
372    /// Scrolls by the given delta, clamping to valid range [0, max_value].
373    /// Returns the actual amount scrolled.
374    pub fn dispatch_raw_delta(&self, delta: f32) -> f32 {
375        let current = self.value_non_reactive();
376        let max = self.max_value();
377        let new_value = (current + delta).clamp(0.0, max);
378        let actual_delta = new_value - current;
379
380        if actual_delta.abs() > 0.001 {
381            self.value.set(new_value);
382
383            self.invalidate();
384        }
385
386        actual_delta
387    }
388
389    /// The main-axis extent of the scroll viewport, as the last measure pass
390    /// resolved it.
391    pub fn viewport_extent(&self) -> f32 {
392        *self.inner().viewport_extent.borrow()
393    }
394
395    /// The whole scrollable content along the main axis.
396    pub fn content_extent(&self) -> f32 {
397        self.viewport_extent() + self.max_value()
398    }
399
400    /// Everything an indicator needs about this scroll, read together so it
401    /// cannot mix a position from one frame with an extent from another.
402    ///
403    /// Read outside composition — during draw, or from a gesture — so following
404    /// a scroll costs no recomposition.
405    pub fn metrics(&self) -> ScrollMetrics {
406        let inner = self.inner();
407        let max_offset = *inner.max_value.borrow();
408        let viewport_extent = *inner.viewport_extent.borrow();
409        ScrollMetrics {
410            offset: self.value_non_reactive(),
411            max_offset,
412            viewport_extent,
413        }
414    }
415
416    pub(crate) fn set_max_value(&self, max: f32) {
417        *self.inner().max_value.borrow_mut() = max;
418    }
419
420    pub(crate) fn set_viewport_extent(&self, extent: f32) {
421        *self.inner().viewport_extent.borrow_mut() = extent;
422    }
423
424    /// Scrolls to the given position immediately.
425    pub fn scroll_to(&self, position: f32) {
426        let max = self.max_value();
427        let clamped = position.clamp(0.0, max);
428
429        self.value.set(clamped);
430
431        self.invalidate();
432    }
433
434    pub(crate) fn add_invalidate_callback(&self, callback: Box<dyn Fn()>) -> u64 {
435        let inner = self.inner();
436        let callback = app_owned_invalidation_callback(callback);
437        let id = inner.invalidate_callbacks.add(Rc::clone(&callback));
438        if inner.pending_invalidation.replace(false) {
439            callback();
440        }
441        id
442    }
443
444    pub(crate) fn remove_invalidate_callback(&self, id: u64) {
445        self.inner().invalidate_callbacks.remove(id);
446    }
447
448    fn invalidate(&self) {
449        let inner = self.inner();
450        let callbacks = inner.invalidate_callbacks.snapshot();
451        if callbacks.is_empty() {
452            inner.pending_invalidation.set(true);
453            return;
454        }
455        for callback in callbacks.values() {
456            callback();
457        }
458    }
459}
460
461fn app_owned_invalidation_callback(callback: Box<dyn Fn()>) -> Rc<dyn Fn()> {
462    let Some(owner) = crate::render_state::current_app_context() else {
463        return Rc::from(callback);
464    };
465    let owner = Rc::downgrade(&owner);
466    Rc::new(move || {
467        if let Some(owner) = owner.upgrade() {
468            owner.enter(&callback);
469        }
470    })
471}
472
473#[derive(Clone)]
474pub(crate) struct ScrollMotionContext {
475    inner: Rc<ScrollMotionContextInner>,
476}
477
478#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
479pub(crate) enum ScrollMotionContextKey {
480    ScrollState {
481        state_id: u64,
482        is_vertical: bool,
483        reverse_scrolling: bool,
484    },
485    LazyList {
486        state_identity: usize,
487        is_vertical: bool,
488        reverse_scrolling: bool,
489    },
490    Draggable {
491        state_identity: usize,
492        is_vertical: bool,
493    },
494}
495
496struct ScrollMotionContextInner {
497    active: Cell<bool>,
498    /// Told when `active` changes: the scrolled state's
499    /// `is_scroll_in_progress` follows it.
500    activity_observer: OnceCell<Box<dyn Fn(bool)>>,
501    transient_active: Cell<bool>,
502    generation: Cell<u64>,
503    invalidate_callbacks: InvalidationCallbacks,
504    pending_invalidation: Cell<bool>,
505    overscroll: OverscrollEffect,
506}
507
508pub(crate) struct ScrollMotionContextStore {
509    contexts: RefCell<HashMap<ScrollMotionContextKey, Weak<ScrollMotionContextInner>>>,
510}
511
512impl ScrollMotionContextStore {
513    pub(crate) fn new() -> Self {
514        Self {
515            contexts: RefCell::new(HashMap::default()),
516        }
517    }
518
519    fn context_for_key(&self, key: ScrollMotionContextKey) -> ScrollMotionContext {
520        let mut contexts = self.contexts.borrow_mut();
521        if let Some(inner) = contexts.get(&key).and_then(Weak::upgrade) {
522            return ScrollMotionContext { inner };
523        }
524
525        let context = ScrollMotionContext::new();
526        contexts.insert(key, Rc::downgrade(&context.inner));
527        contexts.retain(|_, weak| weak.strong_count() > 0);
528        context
529    }
530
531    pub(crate) fn clear_transient_after_frame(&self) {
532        let contexts = {
533            let mut contexts = self.contexts.borrow_mut();
534            let live = contexts
535                .values()
536                .filter_map(Weak::upgrade)
537                .collect::<Vec<_>>();
538            contexts.retain(|_, weak| weak.strong_count() > 0);
539            live
540        };
541        for inner in contexts {
542            ScrollMotionContext { inner }.clear_transient_after_frame();
543        }
544    }
545}
546
547pub(crate) fn scroll_motion_context_for_key(key: ScrollMotionContextKey) -> ScrollMotionContext {
548    crate::render_state::with_scroll_motion_context_store(|store| store.context_for_key(key))
549}
550
551impl ScrollMotionContext {
552    pub(crate) fn new() -> Self {
553        Self {
554            inner: Rc::new(ScrollMotionContextInner {
555                active: Cell::new(false),
556                activity_observer: OnceCell::new(),
557                transient_active: Cell::new(false),
558                generation: Cell::new(0),
559                invalidate_callbacks: InvalidationCallbacks::new(),
560                pending_invalidation: Cell::new(false),
561                overscroll: OverscrollEffect::new(),
562            }),
563        }
564    }
565
566    pub(crate) fn is_active(&self) -> bool {
567        self.inner.active.get() || self.inner.transient_active.get()
568    }
569
570    pub(crate) fn ptr_eq(&self, other: &Self) -> bool {
571        Rc::ptr_eq(&self.inner, &other.inner)
572    }
573
574    pub(crate) fn stable_key(&self) -> usize {
575        Rc::as_ptr(&self.inner) as usize
576    }
577
578    pub(crate) fn overscroll(&self) -> OverscrollEffect {
579        self.inner.overscroll.clone()
580    }
581
582    /// Installs the observer `make` builds unless one is installed: a context
583    /// belongs to one scroll state, so its first observer stays right.
584    pub(crate) fn observe_activity(&self, make: impl FnOnce() -> Box<dyn Fn(bool)>) {
585        self.inner.activity_observer.get_or_init(make);
586    }
587
588    pub(crate) fn set_active(&self, active: bool) {
589        let was_active = self.is_active();
590        if self.inner.active.replace(active) != active
591            && let Some(observer) = self.inner.activity_observer.get()
592        {
593            observer(active);
594        }
595        if !active {
596            self.inner.transient_active.set(false);
597        }
598        if was_active != self.is_active() {
599            self.bump_generation();
600            self.invalidate();
601        }
602    }
603
604    pub(crate) fn activate_for_current_frame(&self) {
605        let was_active = self.is_active();
606        self.inner.transient_active.set(true);
607        self.bump_generation();
608        if !was_active {
609            self.invalidate();
610        }
611    }
612
613    pub(crate) fn add_invalidate_callback(&self, callback: Box<dyn Fn()>) -> u64 {
614        let callback = app_owned_invalidation_callback(callback);
615        let id = self.inner.invalidate_callbacks.add(Rc::clone(&callback));
616        if self.inner.pending_invalidation.replace(false) {
617            callback();
618        }
619        id
620    }
621
622    pub(crate) fn remove_invalidate_callback(&self, id: u64) {
623        self.inner.invalidate_callbacks.remove(id);
624    }
625
626    fn bump_generation(&self) -> u64 {
627        let next = self.inner.generation.get().wrapping_add(1);
628        self.inner.generation.set(next);
629        next
630    }
631
632    fn clear_transient_after_frame(&self) {
633        let was_active = self.is_active();
634        if self.inner.transient_active.replace(false) {
635            self.bump_generation();
636            if was_active != self.is_active() {
637                self.invalidate();
638            }
639        }
640    }
641
642    fn invalidate(&self) {
643        let callbacks = self.inner.invalidate_callbacks.snapshot();
644        if callbacks.is_empty() {
645            self.inner.pending_invalidation.set(true);
646            return;
647        }
648        for callback in callbacks.values() {
649            callback();
650        }
651    }
652}
653
654/// Element for creating a ScrollNode.
655#[derive(Clone)]
656pub struct ScrollElement {
657    state: ScrollState,
658    overscroll: OverscrollEffect,
659    is_vertical: bool,
660    reverse_scrolling: bool,
661}
662
663impl ScrollElement {
664    pub(crate) fn new(
665        state: ScrollState,
666        overscroll: OverscrollEffect,
667        is_vertical: bool,
668        reverse_scrolling: bool,
669    ) -> Self {
670        Self {
671            state,
672            overscroll,
673            is_vertical,
674            reverse_scrolling,
675        }
676    }
677}
678
679impl std::fmt::Debug for ScrollElement {
680    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
681        f.debug_struct("ScrollElement")
682            .field("is_vertical", &self.is_vertical)
683            .field("reverse_scrolling", &self.reverse_scrolling)
684            .finish()
685    }
686}
687
688impl PartialEq for ScrollElement {
689    fn eq(&self, other: &Self) -> bool {
690        self.state == other.state
691            && self.is_vertical == other.is_vertical
692            && self.reverse_scrolling == other.reverse_scrolling
693    }
694}
695
696impl Eq for ScrollElement {}
697
698impl Hash for ScrollElement {
699    fn hash<H: Hasher>(&self, state: &mut H) {
700        self.state.inner.runtime_state_id().hash(state);
701        self.is_vertical.hash(state);
702        self.reverse_scrolling.hash(state);
703    }
704}
705
706impl ModifierNodeElement for ScrollElement {
707    type Node = ScrollNode;
708
709    fn create(&self) -> Self::Node {
710        ScrollNode::new(
711            self.state,
712            self.overscroll.clone(),
713            self.is_vertical,
714            self.reverse_scrolling,
715        )
716    }
717
718    fn key(&self) -> Option<u64> {
719        let mut hasher = cranpose_core::hash::default::new();
720        self.state.id().hash(&mut hasher);
721        self.reverse_scrolling.hash(&mut hasher);
722        self.is_vertical.hash(&mut hasher);
723        Some(hasher.finish())
724    }
725
726    fn update(&self, node: &mut Self::Node) {
727        let needs_invalidation = node.state != self.state
728            || node.is_vertical != self.is_vertical
729            || node.reverse_scrolling != self.reverse_scrolling
730            || !node.overscroll.ptr_eq(&self.overscroll);
731
732        if needs_invalidation {
733            node.state = self.state;
734            node.is_vertical = self.is_vertical;
735            node.reverse_scrolling = self.reverse_scrolling;
736            node.overscroll = self.overscroll.clone();
737        }
738    }
739
740    fn capabilities(&self) -> NodeCapabilities {
741        NodeCapabilities::LAYOUT
742    }
743}
744
745/// ScrollNode layout modifier that physically moves content based on scroll position.
746/// This is the component that actually reads ScrollState and applies the visual offset.
747pub struct ScrollNode {
748    state: ScrollState,
749    overscroll: OverscrollEffect,
750    is_vertical: bool,
751    reverse_scrolling: bool,
752    node_state: NodeState,
753    invalidation_callback_id: Option<u64>,
754    overscroll_callback_id: Option<u64>,
755    node_id: Option<NodeId>,
756}
757
758impl ScrollNode {
759    pub(crate) fn new(
760        state: ScrollState,
761        overscroll: OverscrollEffect,
762        is_vertical: bool,
763        reverse_scrolling: bool,
764    ) -> Self {
765        Self {
766            state,
767            overscroll,
768            is_vertical,
769            reverse_scrolling,
770            node_state: NodeState::default(),
771            invalidation_callback_id: None,
772            overscroll_callback_id: None,
773            node_id: None,
774        }
775    }
776
777    /// Returns a reference to the ScrollState.
778    pub fn state(&self) -> &ScrollState {
779        &self.state
780    }
781}
782
783impl DelegatableNode for ScrollNode {
784    fn node_state(&self) -> &NodeState {
785        &self.node_state
786    }
787}
788
789impl ModifierNode for ScrollNode {
790    fn on_attach(&mut self, context: &mut dyn ModifierNodeContext) {
791        let node_id = context.node_id();
792        self.node_id = node_id;
793
794        if let Some(node_id) = node_id {
795            let callback_id = self.state.add_invalidate_callback(Box::new(move || {
796                crate::schedule_layout_repass(node_id);
797            }));
798            self.invalidation_callback_id = Some(callback_id);
799            let callback_id = self.overscroll.add_invalidate_callback(Box::new(move || {
800                crate::schedule_layout_repass(node_id);
801            }));
802            self.overscroll_callback_id = Some(callback_id);
803        } else {
804            log::debug!(
805                "ScrollNode attached without a NodeId; deferring invalidation registration."
806            );
807        }
808
809        context.invalidate(cranpose_foundation::InvalidationKind::Layout);
810    }
811
812    fn on_detach(&mut self) {
813        if let Some(id) = self.invalidation_callback_id.take() {
814            self.state.remove_invalidate_callback(id);
815        }
816        if let Some(id) = self.overscroll_callback_id.take() {
817            self.overscroll.remove_invalidate_callback(id);
818        }
819    }
820
821    fn as_layout_node(&self) -> Option<&dyn LayoutModifierNode> {
822        Some(self)
823    }
824
825    fn as_layout_node_mut(&mut self) -> Option<&mut dyn LayoutModifierNode> {
826        Some(self)
827    }
828}
829
830impl LayoutModifierNode for ScrollNode {
831    fn measure(
832        &self,
833        _context: &mut dyn ModifierNodeContext,
834        measurable: &dyn Measurable,
835        constraints: Constraints,
836    ) -> LayoutModifierMeasureResult {
837        // The content keeps the incoming minimum and loses only the maximum
838        // along the scroll, as Compose's `ScrollingLayoutNode` measures it
839        // with `constraints.copy(maxHeight = Infinity)`: under
840        // `fill_max_size()` it is at least as tall as the viewport.
841        let scroll_constraints = if self.is_vertical {
842            Constraints {
843                max_height: f32::INFINITY,
844                ..constraints
845            }
846        } else {
847            Constraints {
848                max_width: f32::INFINITY,
849                ..constraints
850            }
851        };
852
853        let placeable = measurable.measure(scroll_constraints);
854
855        let width = placeable.width().min(constraints.max_width);
856        let height = placeable.height().min(constraints.max_height);
857
858        let max_scroll = if self.is_vertical {
859            (placeable.height() - height).max(0.0)
860        } else {
861            (placeable.width() - width).max(0.0)
862        };
863
864        if (self.is_vertical && constraints.max_height.is_finite())
865            || (!self.is_vertical && constraints.max_width.is_finite())
866        {
867            self.state.set_max_value(max_scroll);
868            self.state
869                .set_viewport_extent(if self.is_vertical { height } else { width });
870            self.overscroll
871                .set_dimension(if self.is_vertical { height } else { width });
872        }
873
874        let scroll = self.state.value_non_reactive().clamp(0.0, max_scroll);
875
876        let abs_scroll = if self.reverse_scrolling {
877            scroll - max_scroll
878        } else {
879            -scroll
880        };
881        let abs_scroll = abs_scroll + self.overscroll.offset();
882
883        let (x_offset, y_offset) = if self.is_vertical {
884            (0.0, abs_scroll)
885        } else {
886            (abs_scroll, 0.0)
887        };
888
889        LayoutModifierMeasureResult::new(Size { width, height }, x_offset, y_offset)
890    }
891
892    fn min_intrinsic_width(&self, measurable: &dyn Measurable, height: f32, _density: f32) -> f32 {
893        measurable.min_intrinsic_width(height)
894    }
895
896    fn max_intrinsic_width(&self, measurable: &dyn Measurable, height: f32, _density: f32) -> f32 {
897        measurable.max_intrinsic_width(height)
898    }
899
900    fn min_intrinsic_height(&self, measurable: &dyn Measurable, width: f32, _density: f32) -> f32 {
901        measurable.min_intrinsic_height(width)
902    }
903
904    fn max_intrinsic_height(&self, measurable: &dyn Measurable, width: f32, _density: f32) -> f32 {
905        measurable.max_intrinsic_height(width)
906    }
907}
908
909/// Creates a remembered ScrollState.
910///
911/// This is a convenience function for use in composable functions.
912#[macro_export]
913macro_rules! rememberScrollState {
914    ($initial:expr) => {
915        cranpose_core::remember(|| $crate::scroll::ScrollState::new($initial))
916            .with(|state| state.clone())
917    };
918    () => {
919        rememberScrollState!(0.0)
920    };
921}
922
923#[cfg(test)]
924#[path = "tests/scroll_tests.rs"]
925mod tests;