Skip to main content

cranpose_foundation/lazy/
lazy_list_state.rs

1//! Lazy list state management.
2//!
3//! Provides [`LazyListState`] for controlling and observing lazy list scroll position.
4//!
5//! Design follows Jetpack Compose's LazyListState/LazyListScrollPosition pattern:
6//! - Reactive properties are backed by `MutableState<T>`:
7//!   - `first_visible_item_index`, `first_visible_item_scroll_offset`
8//!   - `can_scroll_forward`, `can_scroll_backward`
9//!   - `stats` (items_in_use, items_in_pool)
10//! - Non-reactive internals (caches, callbacks, prefetch, diagnostic counters) are in inner state
11
12use std::cell::RefCell;
13use std::rc::Rc;
14
15use cranpose_core::MutableState;
16use cranpose_macros::composable;
17
18use super::nearest_range::NearestRangeState;
19use super::prefetch::{PrefetchScheduler, PrefetchStrategy};
20
21/// Statistics about lazy layout item lifecycle.
22///
23/// Used for testing and debugging virtualization behavior.
24#[derive(Clone, Debug, Default, PartialEq)]
25pub struct LazyLayoutStats {
26    /// Number of items currently composed and visible.
27    pub items_in_use: usize,
28
29    /// Number of items in the recycle pool (available for reuse).
30    pub items_in_pool: usize,
31
32    /// Total number of items that have been composed.
33    pub total_composed: usize,
34
35    /// Number of items that were reused instead of newly composed.
36    pub reuse_count: usize,
37}
38
39// ─────────────────────────────────────────────────────────────────────────────
40// LazyListScrollPosition - Reactive scroll position (matches JC design)
41// ─────────────────────────────────────────────────────────────────────────────
42
43/// Contains the current scroll position represented by the first visible item
44/// index and the first visible item scroll offset.
45///
46/// This is a Copy type that holds reactive state. Reading `index` or `scroll_offset`
47/// during composition creates a snapshot dependency for automatic recomposition.
48///
49/// Matches Jetpack Compose's `LazyListScrollPosition` design.
50#[derive(Clone, Copy)]
51pub struct LazyListScrollPosition {
52    /// The index of the first visible item (reactive).
53    index: MutableState<usize>,
54    /// The scroll offset of the first visible item (reactive).
55    scroll_offset: MutableState<f32>,
56    /// Non-reactive internal state (key tracking, nearest range).
57    inner: MutableState<Rc<RefCell<ScrollPositionInner>>>,
58}
59
60/// Non-reactive internal state for scroll position.
61struct ScrollPositionInner {
62    /// The last known key of the item at index position.
63    /// Used for scroll position stability across data changes.
64    last_known_first_item_key: Option<u64>,
65    /// Sliding window range for optimized key lookups.
66    nearest_range_state: NearestRangeState,
67}
68
69impl LazyListScrollPosition {
70    /// Returns the index of the first visible item (reactive read).
71    pub fn index(&self) -> usize {
72        self.index.get()
73    }
74
75    /// Returns the scroll offset of the first visible item (reactive read).
76    pub fn scroll_offset(&self) -> f32 {
77        self.scroll_offset.get()
78    }
79
80    /// Updates the scroll position from a measurement result.
81    ///
82    /// Called after layout measurement to update the reactive scroll position.
83    /// This stores the key for scroll position stability and updates the nearest range.
84    pub(crate) fn update_from_measure_result(
85        &self,
86        first_visible_index: usize,
87        first_visible_scroll_offset: f32,
88        first_visible_item_key: Option<u64>,
89    ) {
90        // Update internal state (key tracking, nearest range)
91        self.inner.with(|rc| {
92            let mut inner = rc.borrow_mut();
93            inner.last_known_first_item_key = first_visible_item_key;
94            inner.nearest_range_state.update(first_visible_index);
95        });
96
97        // Only update reactive state if value changed (avoids recomposition loops)
98        let old_index = self.index.get();
99        if old_index != first_visible_index {
100            self.index.set(first_visible_index);
101        }
102        let old_offset = self.scroll_offset.get();
103        if (old_offset - first_visible_scroll_offset).abs() > 0.001 {
104            self.scroll_offset.set(first_visible_scroll_offset);
105        }
106    }
107
108    /// Requests a new position and clears the last known key.
109    /// Used for programmatic scrolls (scroll_to_item).
110    pub(crate) fn request_position_and_forget_last_known_key(
111        &self,
112        index: usize,
113        scroll_offset: f32,
114    ) {
115        // Update reactive state
116        if self.index.get() != index {
117            self.index.set(index);
118        }
119        if (self.scroll_offset.get() - scroll_offset).abs() > 0.001 {
120            self.scroll_offset.set(scroll_offset);
121        }
122        // Clear key and update nearest range
123        self.inner.with(|rc| {
124            let mut inner = rc.borrow_mut();
125            inner.last_known_first_item_key = None;
126            inner.nearest_range_state.update(index);
127        });
128    }
129
130    /// Adjusts scroll position if the first visible item was moved.
131    /// Returns the adjusted index.
132    pub(crate) fn update_if_first_item_moved<F>(
133        &self,
134        new_item_count: usize,
135        find_by_key: F,
136    ) -> usize
137    where
138        F: Fn(u64) -> Option<usize>,
139    {
140        let current_index = self.index.get();
141        let last_key = self.inner.with(|rc| rc.borrow().last_known_first_item_key);
142
143        let new_index = match last_key {
144            None => current_index.min(new_item_count.saturating_sub(1)),
145            Some(key) => find_by_key(key)
146                .unwrap_or_else(|| current_index.min(new_item_count.saturating_sub(1))),
147        };
148
149        if current_index != new_index {
150            self.index.set(new_index);
151            self.inner.with(|rc| {
152                rc.borrow_mut().nearest_range_state.update(new_index);
153            });
154        }
155        new_index
156    }
157
158    /// Returns the nearest range for optimized key lookups.
159    pub fn nearest_range(&self) -> std::ops::Range<usize> {
160        self.inner
161            .with(|rc| rc.borrow().nearest_range_state.range())
162    }
163}
164
165// ─────────────────────────────────────────────────────────────────────────────
166// LazyListState - Main state object
167// ─────────────────────────────────────────────────────────────────────────────
168
169/// State object for lazy list scroll position tracking.
170///
171/// Holds the current scroll position and provides methods to programmatically
172/// control scrolling. Create with [`remember_lazy_list_state()`] in composition.
173///
174/// This type is `Copy`, so it can be passed to multiple closures without explicit `.clone()` calls.
175///
176/// # Reactive Properties (read during composition triggers recomposition)
177/// - `first_visible_item_index()` - index of first visible item
178/// - `first_visible_item_scroll_offset()` - scroll offset within first item
179/// - `can_scroll_forward()` - whether more items exist below/right
180/// - `can_scroll_backward()` - whether more items exist above/left
181/// - `stats()` - lifecycle statistics (`items_in_use`, `items_in_pool`)
182///
183/// # Non-Reactive Properties
184/// - `stats().total_composed` - total items composed (diagnostic)
185/// - `stats().reuse_count` - items reused from pool (diagnostic)
186/// - `layout_info()` - detailed layout information
187///
188/// # Example
189///
190/// ```rust,ignore
191/// let state = remember_lazy_list_state();
192///
193/// // Scroll to item 50
194/// state.scroll_to_item(50, 0.0);
195///
196/// // Get current visible item (reactive read)
197/// println!("First visible: {}", state.first_visible_item_index());
198/// ```
199#[derive(Clone, Copy)]
200pub struct LazyListState {
201    /// Scroll position with reactive index and offset (matches JC design).
202    scroll_position: LazyListScrollPosition,
203    /// Whether we can scroll forward (reactive, matches JC).
204    can_scroll_forward_state: MutableState<bool>,
205    /// Whether we can scroll backward (reactive, matches JC).
206    can_scroll_backward_state: MutableState<bool>,
207    /// Reactive stats state for triggering recomposition when stats change.
208    /// Only contains items_in_use and items_in_pool (diagnostic counters are in inner).
209    stats_state: MutableState<LazyLayoutStats>,
210    /// Non-reactive internal state (caches, callbacks, prefetch, layout info).
211    inner: MutableState<Rc<RefCell<LazyListStateInner>>>,
212}
213
214// Implement PartialEq by comparing inner pointers for identity.
215// This allows LazyListState to be used as a composable function parameter.
216impl PartialEq for LazyListState {
217    fn eq(&self, other: &Self) -> bool {
218        std::ptr::eq(self.inner_ptr(), other.inner_ptr())
219    }
220}
221
222/// Non-reactive internal state for LazyListState.
223struct LazyListStateInner {
224    /// Scroll delta to be consumed in the next layout pass.
225    scroll_to_be_consumed: f32,
226
227    /// Pending scroll-to-item request.
228    pending_scroll_to_index: Option<(usize, f32)>,
229
230    /// Layout info from the last measure pass.
231    layout_info: LazyListLayoutInfo,
232
233    /// Invalidation callbacks.
234    invalidate_callbacks: Vec<(u64, Rc<dyn Fn()>)>,
235    next_callback_id: u64,
236
237    /// Whether a layout invalidation callback has been registered for this state.
238    /// Used to prevent duplicate registrations on recomposition.
239    has_layout_invalidation_callback: bool,
240
241    /// Diagnostic counters (non-reactive - not typically displayed in UI).
242    total_composed: usize,
243    reuse_count: usize,
244
245    /// Cache of recently measured item sizes (index -> main_axis_size).
246    item_size_cache: std::collections::HashMap<usize, f32>,
247    /// LRU order tracking - front is oldest, back is newest.
248    item_size_lru: std::collections::VecDeque<usize>,
249
250    /// Running average of measured item sizes for estimation.
251    average_item_size: f32,
252    total_measured_items: usize,
253
254    /// Prefetch scheduler for pre-composing items.
255    prefetch_scheduler: PrefetchScheduler,
256
257    /// Prefetch strategy configuration.
258    prefetch_strategy: PrefetchStrategy,
259
260    /// Last scroll delta direction for prefetch.
261    last_scroll_direction: f32,
262}
263
264/// Creates a remembered [`LazyListState`] with default initial position.
265///
266/// This is the recommended way to create a `LazyListState` in composition.
267/// The returned state is `Copy` and can be passed to multiple closures without `.clone()`.
268///
269/// # Example
270///
271/// ```rust,ignore
272/// let list_state = remember_lazy_list_state();
273///
274/// // Pass to multiple closures - no .clone() needed!
275/// LazyColumn(modifier, list_state, spec, content);
276/// Button(move || list_state.scroll_to_item(0, 0.0));
277/// ```
278#[composable]
279pub fn remember_lazy_list_state() -> LazyListState {
280    remember_lazy_list_state_with_position(0, 0.0)
281}
282
283/// Creates a remembered [`LazyListState`] with the specified initial position.
284///
285/// The returned state is `Copy` and can be passed to multiple closures without `.clone()`.
286#[composable]
287pub fn remember_lazy_list_state_with_position(
288    initial_first_visible_item_index: usize,
289    initial_first_visible_item_scroll_offset: f32,
290) -> LazyListState {
291    // Create scroll position with reactive fields (matches JC LazyListScrollPosition)
292    let scroll_position = LazyListScrollPosition {
293        index: cranpose_core::useState(|| initial_first_visible_item_index),
294        scroll_offset: cranpose_core::useState(|| initial_first_visible_item_scroll_offset),
295        inner: cranpose_core::useState(|| {
296            Rc::new(RefCell::new(ScrollPositionInner {
297                last_known_first_item_key: None,
298                nearest_range_state: NearestRangeState::new(initial_first_visible_item_index),
299            }))
300        }),
301    };
302
303    // Non-reactive internal state
304    let inner = cranpose_core::useState(|| {
305        Rc::new(RefCell::new(LazyListStateInner {
306            scroll_to_be_consumed: 0.0,
307            pending_scroll_to_index: None,
308            layout_info: LazyListLayoutInfo::default(),
309            invalidate_callbacks: Vec::new(),
310            next_callback_id: 1,
311            has_layout_invalidation_callback: false,
312            total_composed: 0,
313            reuse_count: 0,
314            item_size_cache: std::collections::HashMap::new(),
315            item_size_lru: std::collections::VecDeque::new(),
316            average_item_size: super::DEFAULT_ITEM_SIZE_ESTIMATE,
317            total_measured_items: 0,
318            prefetch_scheduler: PrefetchScheduler::new(),
319            prefetch_strategy: PrefetchStrategy::default(),
320            last_scroll_direction: 0.0,
321        }))
322    });
323
324    // Reactive state
325    let can_scroll_forward_state = cranpose_core::useState(|| false);
326    let can_scroll_backward_state = cranpose_core::useState(|| false);
327    let stats_state = cranpose_core::useState(LazyLayoutStats::default);
328
329    LazyListState {
330        scroll_position,
331        can_scroll_forward_state,
332        can_scroll_backward_state,
333        stats_state,
334        inner,
335    }
336}
337
338impl LazyListState {
339    /// Returns a pointer to the inner state for unique identification.
340    /// Used by scroll gesture detection to create unique keys.
341    pub fn inner_ptr(&self) -> *const () {
342        self.inner.with(|rc| Rc::as_ptr(rc) as *const ())
343    }
344
345    /// Returns the index of the first visible item.
346    ///
347    /// When called during composition, this creates a reactive subscription
348    /// so that changes to the index will trigger recomposition.
349    pub fn first_visible_item_index(&self) -> usize {
350        // Delegate to scroll_position (reactive read)
351        self.scroll_position.index()
352    }
353
354    /// Returns the scroll offset of the first visible item.
355    ///
356    /// This is the amount the first item is scrolled off-screen (positive = scrolled up/left).
357    /// When called during composition, this creates a reactive subscription
358    /// so that changes to the offset will trigger recomposition.
359    pub fn first_visible_item_scroll_offset(&self) -> f32 {
360        // Delegate to scroll_position (reactive read)
361        self.scroll_position.scroll_offset()
362    }
363
364    /// Returns the layout info from the last measure pass.
365    pub fn layout_info(&self) -> LazyListLayoutInfo {
366        self.inner.with(|rc| rc.borrow().layout_info.clone())
367    }
368
369    /// Returns the current item lifecycle statistics.
370    ///
371    /// When called during composition, this creates a reactive subscription
372    /// so that changes to `items_in_use` or `items_in_pool` will trigger recomposition.
373    /// The `total_composed` and `reuse_count` fields are diagnostic and non-reactive.
374    pub fn stats(&self) -> LazyLayoutStats {
375        // Read reactive state (creates subscription) and combine with non-reactive counters
376        let reactive = self.stats_state.get();
377        let (total_composed, reuse_count) = self.inner.with(|rc| {
378            let inner = rc.borrow();
379            (inner.total_composed, inner.reuse_count)
380        });
381        LazyLayoutStats {
382            items_in_use: reactive.items_in_use,
383            items_in_pool: reactive.items_in_pool,
384            total_composed,
385            reuse_count,
386        }
387    }
388
389    /// Updates the item lifecycle statistics.
390    ///
391    /// Called by the layout measurement after updating slot pools.
392    /// Triggers recomposition if `items_in_use` or `items_in_pool` changed.
393    pub fn update_stats(&self, items_in_use: usize, items_in_pool: usize) {
394        let current = self.stats_state.get();
395
396        // Hysteresis: only trigger reactive update when items_in_use INCREASES
397        // or DECREASES by more than 1. This prevents the 5→4→5→4 oscillation
398        // that happens at boundary conditions during slow upward scroll.
399        //
400        // Rationale:
401        // - Items becoming visible (increase): user should see count update immediately
402        // - Items going off-screen by 1: minor fluctuation, wait for significant change
403        // - Items going off-screen by 2+: significant change, update immediately
404        let should_update_reactive = if items_in_use > current.items_in_use {
405            // Increase: always update (new items visible)
406            true
407        } else if items_in_use < current.items_in_use {
408            // Decrease: only update if by more than 1 (prevents oscillation)
409            current.items_in_use - items_in_use > 1
410        } else {
411            false
412        };
413
414        if should_update_reactive {
415            self.stats_state.set(LazyLayoutStats {
416                items_in_use,
417                items_in_pool,
418                ..current
419            });
420        }
421        // Note: pool-only changes are intentionally not committed to reactive state
422        // to prevent the 5→4→5 oscillation that caused slow upward scroll hang.
423    }
424
425    /// Records that an item was composed (either new or reused).
426    ///
427    /// This updates diagnostic counters in non-reactive state.
428    /// Does NOT trigger recomposition.
429    pub fn record_composition(&self, was_reused: bool) {
430        self.inner.with(|rc| {
431            let mut inner = rc.borrow_mut();
432            inner.total_composed += 1;
433            if was_reused {
434                inner.reuse_count += 1;
435            }
436        });
437    }
438
439    /// Records the scroll direction for prefetch calculations.
440    /// Positive = scrolling forward (content moving up), negative = backward.
441    pub fn record_scroll_direction(&self, delta: f32) {
442        if delta.abs() > 0.001 {
443            self.inner.with(|rc| {
444                rc.borrow_mut().last_scroll_direction = delta.signum();
445            });
446        }
447    }
448
449    /// Updates the prefetch queue based on current visible items.
450    /// Should be called after measurement to queue items for pre-composition.
451    pub fn update_prefetch_queue(
452        &self,
453        first_visible_index: usize,
454        last_visible_index: usize,
455        total_items: usize,
456    ) {
457        self.inner.with(|rc| {
458            let mut inner = rc.borrow_mut();
459            let direction = inner.last_scroll_direction;
460            let strategy = inner.prefetch_strategy.clone();
461            inner.prefetch_scheduler.update(
462                first_visible_index,
463                last_visible_index,
464                total_items,
465                direction,
466                &strategy,
467            );
468        });
469    }
470
471    /// Returns the indices that should be prefetched.
472    /// Consumes the prefetch queue.
473    pub fn take_prefetch_indices(&self) -> Vec<usize> {
474        self.inner.with(|rc| {
475            let mut inner = rc.borrow_mut();
476            let mut indices = Vec::new();
477            while let Some(idx) = inner.prefetch_scheduler.next_prefetch() {
478                indices.push(idx);
479            }
480            indices
481        })
482    }
483
484    /// Scrolls to the specified item index.
485    ///
486    /// # Arguments
487    /// * `index` - The index of the item to scroll to
488    /// * `scroll_offset` - Additional offset within the item (default 0)
489    pub fn scroll_to_item(&self, index: usize, scroll_offset: f32) {
490        // Store pending scroll request
491        self.inner.with(|rc| {
492            rc.borrow_mut().pending_scroll_to_index = Some((index, scroll_offset));
493        });
494
495        // Delegate to scroll_position which handles reactive updates and key clearing
496        self.scroll_position
497            .request_position_and_forget_last_known_key(index, scroll_offset);
498
499        self.invalidate();
500    }
501
502    /// Dispatches a raw scroll delta.
503    ///
504    /// Returns the amount of scroll actually consumed.
505    ///
506    /// This triggers layout invalidation via registered callbacks. The callbacks are
507    /// registered by LazyColumnImpl/LazyRowImpl with schedule_layout_repass(node_id),
508    /// which provides O(subtree) performance instead of O(entire app).
509    pub fn dispatch_scroll_delta(&self, delta: f32) -> f32 {
510        self.inner.with(|rc| {
511            let mut inner = rc.borrow_mut();
512            inner.scroll_to_be_consumed += delta;
513        });
514        self.invalidate();
515        delta // Will be adjusted during layout
516    }
517
518    /// Consumes and returns the pending scroll delta.
519    ///
520    /// Called by the layout during measure.
521    pub(crate) fn consume_scroll_delta(&self) -> f32 {
522        self.inner.with(|rc| {
523            let mut inner = rc.borrow_mut();
524            let delta = inner.scroll_to_be_consumed;
525            inner.scroll_to_be_consumed = 0.0;
526            delta
527        })
528    }
529
530    /// Peeks at the pending scroll delta without consuming it.
531    ///
532    /// Used for direction inference before measurement consumes the delta.
533    /// This is more accurate than comparing first visible index, especially for:
534    /// - Scrolling within the same item (partial scroll)
535    /// - Variable height items where scroll offset changes without index change
536    pub fn peek_scroll_delta(&self) -> f32 {
537        self.inner.with(|rc| rc.borrow().scroll_to_be_consumed)
538    }
539
540    /// Consumes and returns the pending scroll-to-item request.
541    ///
542    /// Called by the layout during measure.
543    pub(crate) fn consume_scroll_to_index(&self) -> Option<(usize, f32)> {
544        self.inner
545            .with(|rc| rc.borrow_mut().pending_scroll_to_index.take())
546    }
547
548    /// Caches the measured size of an item for scroll estimation.
549    ///
550    /// Uses a HashMap + VecDeque LRU pattern with O(1) insertion and eviction.
551    /// Re-measurement of existing items (uncommon during normal scrolling)
552    /// requires O(n) VecDeque position lookup, but the cache is small (100 items).
553    ///
554    /// # Performance Note
555    /// If profiling shows this as a bottleneck, consider using the `lru` crate
556    /// for O(1) update-in-place operations, or a linked hash map.
557    pub fn cache_item_size(&self, index: usize, size: f32) {
558        use std::collections::hash_map::Entry;
559        self.inner.with(|rc| {
560            let mut inner = rc.borrow_mut();
561            const MAX_CACHE_SIZE: usize = 100;
562
563            // Check if already in cache (update existing)
564            if let Entry::Occupied(mut entry) = inner.item_size_cache.entry(index) {
565                // Update value and move to back of LRU
566                entry.insert(size);
567                // Remove old position from LRU (O(n) but rare - only on re-measurement)
568                if let Some(pos) = inner.item_size_lru.iter().position(|&k| k == index) {
569                    inner.item_size_lru.remove(pos);
570                }
571                inner.item_size_lru.push_back(index);
572                return;
573            }
574
575            // Evict oldest entries until under limit - O(1) per eviction
576            while inner.item_size_cache.len() >= MAX_CACHE_SIZE {
577                if let Some(oldest) = inner.item_size_lru.pop_front() {
578                    // Only remove if still in cache (may have been updated)
579                    if inner.item_size_cache.remove(&oldest).is_some() {
580                        break; // Removed one entry, now under limit
581                    }
582                } else {
583                    break; // LRU empty, shouldn't happen
584                }
585            }
586
587            // Add new entry
588            inner.item_size_cache.insert(index, size);
589            inner.item_size_lru.push_back(index);
590
591            // Update running average
592            inner.total_measured_items += 1;
593            let n = inner.total_measured_items as f32;
594            inner.average_item_size = inner.average_item_size * ((n - 1.0) / n) + size / n;
595        });
596    }
597
598    /// Gets a cached item size if available.
599    pub fn get_cached_size(&self, index: usize) -> Option<f32> {
600        self.inner
601            .with(|rc| rc.borrow().item_size_cache.get(&index).copied())
602    }
603
604    /// Returns the running average of measured item sizes.
605    pub fn average_item_size(&self) -> f32 {
606        self.inner.with(|rc| rc.borrow().average_item_size)
607    }
608
609    /// Returns the current nearest range for optimized key lookup.
610    pub fn nearest_range(&self) -> std::ops::Range<usize> {
611        // Delegate to scroll_position
612        self.scroll_position.nearest_range()
613    }
614
615    /// Updates the scroll position from a layout pass.
616    ///
617    /// Called by the layout after measurement.
618    pub(crate) fn update_scroll_position(
619        &self,
620        first_visible_item_index: usize,
621        first_visible_item_scroll_offset: f32,
622    ) {
623        self.scroll_position.update_from_measure_result(
624            first_visible_item_index,
625            first_visible_item_scroll_offset,
626            None,
627        );
628    }
629
630    /// Updates the scroll position and stores the key of the first visible item.
631    ///
632    /// Called by the layout after measurement to enable scroll position stability.
633    pub(crate) fn update_scroll_position_with_key(
634        &self,
635        first_visible_item_index: usize,
636        first_visible_item_scroll_offset: f32,
637        first_visible_item_key: u64,
638    ) {
639        self.scroll_position.update_from_measure_result(
640            first_visible_item_index,
641            first_visible_item_scroll_offset,
642            Some(first_visible_item_key),
643        );
644    }
645
646    /// Adjusts scroll position if the first visible item was moved due to data changes.
647    ///
648    /// Matches JC's `updateScrollPositionIfTheFirstItemWasMoved`.
649    /// If items were inserted/removed before the current scroll position,
650    /// this finds the item by its key and updates the index accordingly.
651    ///
652    /// Returns the adjusted first visible item index.
653    pub fn update_scroll_position_if_item_moved<F>(
654        &self,
655        new_item_count: usize,
656        get_index_by_key: F,
657    ) -> usize
658    where
659        F: Fn(u64) -> Option<usize>,
660    {
661        // Delegate to scroll_position
662        self.scroll_position
663            .update_if_first_item_moved(new_item_count, get_index_by_key)
664    }
665
666    /// Updates the layout info from a layout pass.
667    pub(crate) fn update_layout_info(&self, info: LazyListLayoutInfo) {
668        self.inner.with(|rc| rc.borrow_mut().layout_info = info);
669    }
670
671    /// Returns whether we can scroll forward (more items below/right).
672    ///
673    /// When called during composition, this creates a reactive subscription
674    /// so that changes will trigger recomposition.
675    pub fn can_scroll_forward(&self) -> bool {
676        self.can_scroll_forward_state.get()
677    }
678
679    /// Returns whether we can scroll backward (more items above/left).
680    ///
681    /// When called during composition, this creates a reactive subscription
682    /// so that changes will trigger recomposition.
683    pub fn can_scroll_backward(&self) -> bool {
684        self.can_scroll_backward_state.get()
685    }
686
687    /// Updates the scroll bounds after layout measurement.
688    ///
689    /// Called by the layout after measurement to update can_scroll_forward/backward.
690    pub(crate) fn update_scroll_bounds(&self) {
691        // Compute can_scroll_forward from layout info
692        let can_forward = self.inner.with(|rc| {
693            let inner = rc.borrow();
694            let info = &inner.layout_info;
695            // Use effective viewport end (accounting for after_content_padding)
696            // Without this, lists with padding can report false while still scrollable
697            let viewport_end = info.viewport_size - info.after_content_padding;
698            if let Some(last_visible) = info.visible_items_info.last() {
699                last_visible.index < info.total_items_count.saturating_sub(1)
700                    || (last_visible.offset + last_visible.size) > viewport_end
701            } else {
702                false
703            }
704        });
705
706        // Compute can_scroll_backward from scroll position
707        let can_backward =
708            self.scroll_position.index() > 0 || self.scroll_position.scroll_offset() > 0.0;
709
710        // Update reactive state only if changed
711        if self.can_scroll_forward_state.get() != can_forward {
712            self.can_scroll_forward_state.set(can_forward);
713        }
714        if self.can_scroll_backward_state.get() != can_backward {
715            self.can_scroll_backward_state.set(can_backward);
716        }
717    }
718
719    /// Adds an invalidation callback.
720    pub fn add_invalidate_callback(&self, callback: Rc<dyn Fn()>) -> u64 {
721        self.inner.with(|rc| {
722            let mut inner = rc.borrow_mut();
723            let id = inner.next_callback_id;
724            inner.next_callback_id += 1;
725            inner.invalidate_callbacks.push((id, callback));
726            id
727        })
728    }
729
730    /// Tries to register a layout invalidation callback.
731    ///
732    /// Returns true if the callback was registered, false if one was already registered.
733    /// This prevents duplicate registrations on recomposition.
734    pub fn try_register_layout_callback(&self, callback: Rc<dyn Fn()>) -> bool {
735        self.inner.with(|rc| {
736            let mut inner = rc.borrow_mut();
737            if inner.has_layout_invalidation_callback {
738                return false;
739            }
740            inner.has_layout_invalidation_callback = true;
741            let id = inner.next_callback_id;
742            inner.next_callback_id += 1;
743            inner.invalidate_callbacks.push((id, callback));
744            true
745        })
746    }
747
748    /// Removes an invalidation callback.
749    pub fn remove_invalidate_callback(&self, id: u64) {
750        self.inner.with(|rc| {
751            rc.borrow_mut()
752                .invalidate_callbacks
753                .retain(|(cb_id, _)| *cb_id != id);
754        });
755    }
756
757    fn invalidate(&self) {
758        // Clone callbacks to avoid holding the borrow while calling them
759        // This prevents re-entrancy issues if a callback triggers another state update
760        let callbacks: Vec<_> = self.inner.with(|rc| {
761            rc.borrow()
762                .invalidate_callbacks
763                .iter()
764                .map(|(_, cb)| Rc::clone(cb))
765                .collect()
766        });
767
768        for callback in callbacks {
769            callback();
770        }
771    }
772}
773
774/// Information about the currently visible items in a lazy list.
775#[derive(Clone, Default, Debug)]
776pub struct LazyListLayoutInfo {
777    /// Information about each visible item.
778    pub visible_items_info: Vec<LazyListItemInfo>,
779
780    /// Total number of items in the list.
781    pub total_items_count: usize,
782
783    /// Size of the viewport in the main axis.
784    pub viewport_size: f32,
785
786    /// Start offset of the viewport (content padding before).
787    pub viewport_start_offset: f32,
788
789    /// End offset of the viewport (content padding after).
790    pub viewport_end_offset: f32,
791
792    /// Content padding before the first item.
793    pub before_content_padding: f32,
794
795    /// Content padding after the last item.
796    pub after_content_padding: f32,
797}
798
799/// Information about a single visible item in a lazy list.
800#[derive(Clone, Debug)]
801pub struct LazyListItemInfo {
802    /// Index of the item in the data source.
803    pub index: usize,
804
805    /// Key of the item.
806    pub key: u64,
807
808    /// Offset of the item from the start of the list content.
809    pub offset: f32,
810
811    /// Size of the item in the main axis.
812    pub size: f32,
813}
814
815/// Test helpers for creating LazyListState without composition context.
816#[cfg(test)]
817pub mod test_helpers {
818    use super::*;
819    use cranpose_core::{DefaultScheduler, Runtime};
820    use std::sync::Arc;
821
822    /// Creates a test runtime and keeps it alive for the duration of the closure.
823    /// Use this to create LazyListState in unit tests.
824    pub fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
825        let _runtime = Runtime::new(Arc::new(DefaultScheduler));
826        f()
827    }
828
829    /// Creates a new LazyListState for testing.
830    /// Must be called within `with_test_runtime`.
831    pub fn new_lazy_list_state() -> LazyListState {
832        new_lazy_list_state_with_position(0, 0.0)
833    }
834
835    /// Creates a new LazyListState for testing with initial position.
836    /// Must be called within `with_test_runtime`.
837    pub fn new_lazy_list_state_with_position(
838        initial_first_visible_item_index: usize,
839        initial_first_visible_item_scroll_offset: f32,
840    ) -> LazyListState {
841        // Create scroll position with reactive fields (matches JC LazyListScrollPosition)
842        let scroll_position = LazyListScrollPosition {
843            index: cranpose_core::mutableStateOf(initial_first_visible_item_index),
844            scroll_offset: cranpose_core::mutableStateOf(initial_first_visible_item_scroll_offset),
845            inner: cranpose_core::mutableStateOf(Rc::new(RefCell::new(ScrollPositionInner {
846                last_known_first_item_key: None,
847                nearest_range_state: NearestRangeState::new(initial_first_visible_item_index),
848            }))),
849        };
850
851        // Non-reactive internal state
852        let inner = cranpose_core::mutableStateOf(Rc::new(RefCell::new(LazyListStateInner {
853            scroll_to_be_consumed: 0.0,
854            pending_scroll_to_index: None,
855            layout_info: LazyListLayoutInfo::default(),
856            invalidate_callbacks: Vec::new(),
857            next_callback_id: 1,
858            has_layout_invalidation_callback: false,
859            total_composed: 0,
860            reuse_count: 0,
861            item_size_cache: std::collections::HashMap::new(),
862            item_size_lru: std::collections::VecDeque::new(),
863            average_item_size: super::super::DEFAULT_ITEM_SIZE_ESTIMATE,
864            total_measured_items: 0,
865            prefetch_scheduler: PrefetchScheduler::new(),
866            prefetch_strategy: PrefetchStrategy::default(),
867            last_scroll_direction: 0.0,
868        })));
869
870        // Reactive state
871        let can_scroll_forward_state = cranpose_core::mutableStateOf(false);
872        let can_scroll_backward_state = cranpose_core::mutableStateOf(false);
873        let stats_state = cranpose_core::mutableStateOf(LazyLayoutStats::default());
874
875        LazyListState {
876            scroll_position,
877            can_scroll_forward_state,
878            can_scroll_backward_state,
879            stats_state,
880            inner,
881        }
882    }
883}