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