Skip to main content

cranpose_ui/
scroll.rs

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