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        let has_scroll_bounds = self
528            .inner
529            .with(|rc| rc.borrow().layout_info.total_items_count > 0);
530        let pushing_forward = delta < -0.001;
531        let pushing_backward = delta > 0.001;
532        let blocked_by_bounds = has_scroll_bounds
533            && ((pushing_forward && !self.can_scroll_forward())
534                || (pushing_backward && !self.can_scroll_backward()));
535
536        if blocked_by_bounds {
537            let should_invalidate = self.inner.with(|rc| {
538                let mut inner = rc.borrow_mut();
539                let pending_before = inner.scroll_to_be_consumed;
540                // If we're already at an edge, clear stale backlog in the same blocked direction.
541                if pending_before.abs() > 0.001 && pending_before.signum() == delta.signum() {
542                    inner.scroll_to_be_consumed = 0.0;
543                }
544                if lazy_measure_telemetry_enabled() {
545                    log::warn!(
546                        "[lazy-measure-telemetry] dispatch_scroll_delta blocked_by_bounds delta={:.2} pending_before={:.2} pending_after={:.2}",
547                        delta,
548                        pending_before,
549                        inner.scroll_to_be_consumed
550                    );
551                }
552                (inner.scroll_to_be_consumed - pending_before).abs() > 0.001
553            });
554            if should_invalidate {
555                self.invalidate();
556            }
557            return 0.0;
558        }
559
560        let should_invalidate = self.inner.with(|rc| {
561            let mut inner = rc.borrow_mut();
562            let pending_before = inner.scroll_to_be_consumed;
563            let pending = inner.scroll_to_be_consumed;
564            let reverse_input = pending.abs() > 0.001
565                && delta.abs() > 0.001
566                && pending.signum() != delta.signum();
567            if reverse_input {
568                if lazy_measure_telemetry_enabled() {
569                    log::warn!(
570                        "[lazy-measure-telemetry] dispatch_scroll_delta direction_change pending={:.2} new_delta={:.2}",
571                        pending,
572                        delta
573                    );
574                }
575                // When gesture direction reverses, stale unconsumed backlog from the previous
576                // direction causes "snap back" behavior on slow frames. Keep only the latest
577                // direction intent.
578                inner.scroll_to_be_consumed = delta;
579            } else {
580                inner.scroll_to_be_consumed += delta;
581            }
582            inner.scroll_to_be_consumed = inner
583                .scroll_to_be_consumed
584                .clamp(-MAX_PENDING_SCROLL_DELTA, MAX_PENDING_SCROLL_DELTA);
585            if lazy_measure_telemetry_enabled() {
586                log::warn!(
587                    "[lazy-measure-telemetry] dispatch_scroll_delta delta={:.2} pending={:.2}",
588                    delta,
589                    inner.scroll_to_be_consumed
590                );
591            }
592            (inner.scroll_to_be_consumed - pending_before).abs() > 0.001
593        });
594        if should_invalidate {
595            self.invalidate();
596        }
597        delta // Will be adjusted during layout
598    }
599
600    /// Consumes and returns the pending scroll delta.
601    ///
602    /// Called by the layout during measure.
603    pub(crate) fn consume_scroll_delta(&self) -> f32 {
604        self.inner.with(|rc| {
605            let mut inner = rc.borrow_mut();
606            let delta = inner.scroll_to_be_consumed;
607            inner.scroll_to_be_consumed = 0.0;
608            delta
609        })
610    }
611
612    /// Peeks at the pending scroll delta without consuming it.
613    ///
614    /// Used for direction inference before measurement consumes the delta.
615    /// This is more accurate than comparing first visible index, especially for:
616    /// - Scrolling within the same item (partial scroll)
617    /// - Variable height items where scroll offset changes without index change
618    pub fn peek_scroll_delta(&self) -> f32 {
619        self.inner.with(|rc| rc.borrow().scroll_to_be_consumed)
620    }
621
622    /// Consumes and returns the pending scroll-to-item request.
623    ///
624    /// Called by the layout during measure.
625    pub(crate) fn consume_scroll_to_index(&self) -> Option<(usize, f32)> {
626        self.inner
627            .with(|rc| rc.borrow_mut().pending_scroll_to_index.take())
628    }
629
630    /// Caches the measured size of an item for scroll estimation.
631    ///
632    /// Uses a HashMap + VecDeque LRU pattern with O(1) insertion and eviction.
633    /// Re-measurement of existing items (uncommon during normal scrolling)
634    /// requires O(n) VecDeque position lookup, but the cache is small (100 items).
635    ///
636    /// # Performance Note
637    /// If profiling shows this as a bottleneck, consider using the `lru` crate
638    /// for O(1) update-in-place operations, or a linked hash map.
639    pub fn cache_item_size(&self, index: usize, size: f32) {
640        use std::collections::hash_map::Entry;
641        self.inner.with(|rc| {
642            let mut inner = rc.borrow_mut();
643            const MAX_CACHE_SIZE: usize = 100;
644
645            // Check if already in cache (update existing)
646            if let Entry::Occupied(mut entry) = inner.item_size_cache.entry(index) {
647                // Update value and move to back of LRU
648                entry.insert(size);
649                // Remove old position from LRU (O(n) but rare - only on re-measurement)
650                if let Some(pos) = inner.item_size_lru.iter().position(|&k| k == index) {
651                    inner.item_size_lru.remove(pos);
652                }
653                inner.item_size_lru.push_back(index);
654                return;
655            }
656
657            // Evict oldest entries until under limit - O(1) per eviction
658            while inner.item_size_cache.len() >= MAX_CACHE_SIZE {
659                if let Some(oldest) = inner.item_size_lru.pop_front() {
660                    // Only remove if still in cache (may have been updated)
661                    if inner.item_size_cache.remove(&oldest).is_some() {
662                        break; // Removed one entry, now under limit
663                    }
664                } else {
665                    break; // LRU empty, shouldn't happen
666                }
667            }
668
669            // Add new entry
670            inner.item_size_cache.insert(index, size);
671            inner.item_size_lru.push_back(index);
672
673            // Update running average
674            inner.total_measured_items += 1;
675            let n = inner.total_measured_items as f32;
676            inner.average_item_size = inner.average_item_size * ((n - 1.0) / n) + size / n;
677        });
678    }
679
680    /// Gets a cached item size if available.
681    pub fn get_cached_size(&self, index: usize) -> Option<f32> {
682        self.inner
683            .with(|rc| rc.borrow().item_size_cache.get(&index).copied())
684    }
685
686    /// Returns the running average of measured item sizes.
687    pub fn average_item_size(&self) -> f32 {
688        self.inner.with(|rc| rc.borrow().average_item_size)
689    }
690
691    /// Returns the current nearest range for optimized key lookup.
692    pub fn nearest_range(&self) -> std::ops::Range<usize> {
693        // Delegate to scroll_position
694        self.scroll_position.nearest_range()
695    }
696
697    /// Updates the scroll position from a layout pass.
698    ///
699    /// Called by the layout after measurement.
700    pub(crate) fn update_scroll_position(
701        &self,
702        first_visible_item_index: usize,
703        first_visible_item_scroll_offset: f32,
704    ) {
705        self.scroll_position.update_from_measure_result(
706            first_visible_item_index,
707            first_visible_item_scroll_offset,
708            None,
709        );
710    }
711
712    /// Updates the scroll position and stores the key of the first visible item.
713    ///
714    /// Called by the layout after measurement to enable scroll position stability.
715    pub(crate) fn update_scroll_position_with_key(
716        &self,
717        first_visible_item_index: usize,
718        first_visible_item_scroll_offset: f32,
719        first_visible_item_key: u64,
720    ) {
721        self.scroll_position.update_from_measure_result(
722            first_visible_item_index,
723            first_visible_item_scroll_offset,
724            Some(first_visible_item_key),
725        );
726    }
727
728    /// Adjusts scroll position if the first visible item was moved due to data changes.
729    ///
730    /// Matches JC's `updateScrollPositionIfTheFirstItemWasMoved`.
731    /// If items were inserted/removed before the current scroll position,
732    /// this finds the item by its key and updates the index accordingly.
733    ///
734    /// Returns the adjusted first visible item index.
735    pub fn update_scroll_position_if_item_moved<F>(
736        &self,
737        new_item_count: usize,
738        get_index_by_key: F,
739    ) -> usize
740    where
741        F: Fn(u64) -> Option<usize>,
742    {
743        // Delegate to scroll_position
744        self.scroll_position
745            .update_if_first_item_moved(new_item_count, get_index_by_key)
746    }
747
748    /// Updates the layout info from a layout pass.
749    pub(crate) fn update_layout_info(&self, info: LazyListLayoutInfo) {
750        self.inner.with(|rc| rc.borrow_mut().layout_info = info);
751    }
752
753    /// Returns whether we can scroll forward (more items below/right).
754    ///
755    /// When called during composition, this creates a reactive subscription
756    /// so that changes will trigger recomposition.
757    pub fn can_scroll_forward(&self) -> bool {
758        self.can_scroll_forward_state.get()
759    }
760
761    /// Returns whether we can scroll backward (more items above/left).
762    ///
763    /// When called during composition, this creates a reactive subscription
764    /// so that changes will trigger recomposition.
765    pub fn can_scroll_backward(&self) -> bool {
766        self.can_scroll_backward_state.get()
767    }
768
769    /// Updates the scroll bounds after layout measurement.
770    ///
771    /// Called by the layout after measurement to update can_scroll_forward/backward.
772    pub(crate) fn update_scroll_bounds(&self) {
773        // Compute can_scroll_forward from layout info
774        let can_forward = self.inner.with(|rc| {
775            let inner = rc.borrow();
776            let info = &inner.layout_info;
777            // Use effective viewport end (accounting for after_content_padding)
778            // Without this, lists with padding can report false while still scrollable
779            let viewport_end = info.viewport_size - info.after_content_padding;
780            if let Some(last_visible) = info.visible_items_info.last() {
781                last_visible.index < info.total_items_count.saturating_sub(1)
782                    || (last_visible.offset + last_visible.size) > viewport_end
783            } else {
784                false
785            }
786        });
787
788        // Compute can_scroll_backward from scroll position
789        let can_backward =
790            self.scroll_position.index() > 0 || self.scroll_position.scroll_offset() > 0.0;
791
792        // Update reactive state only if changed
793        if self.can_scroll_forward_state.get() != can_forward {
794            self.can_scroll_forward_state.set(can_forward);
795        }
796        if self.can_scroll_backward_state.get() != can_backward {
797            self.can_scroll_backward_state.set(can_backward);
798        }
799    }
800
801    /// Adds an invalidation callback.
802    pub fn add_invalidate_callback(&self, callback: Rc<dyn Fn()>) -> u64 {
803        self.inner.with(|rc| {
804            let mut inner = rc.borrow_mut();
805            let id = inner.next_callback_id;
806            inner.next_callback_id += 1;
807            inner.invalidate_callbacks.push((id, callback));
808            id
809        })
810    }
811
812    /// Tries to register a layout invalidation callback.
813    ///
814    /// Returns true if the callback was registered, false if one was already registered.
815    /// This prevents duplicate registrations on recomposition.
816    pub fn try_register_layout_callback(&self, callback: Rc<dyn Fn()>) -> bool {
817        self.inner.with(|rc| {
818            let mut inner = rc.borrow_mut();
819            if inner.has_layout_invalidation_callback {
820                return false;
821            }
822            inner.has_layout_invalidation_callback = true;
823            let id = inner.next_callback_id;
824            inner.next_callback_id += 1;
825            inner.invalidate_callbacks.push((id, callback));
826            true
827        })
828    }
829
830    /// Removes an invalidation callback.
831    pub fn remove_invalidate_callback(&self, id: u64) {
832        self.inner.with(|rc| {
833            rc.borrow_mut()
834                .invalidate_callbacks
835                .retain(|(cb_id, _)| *cb_id != id);
836        });
837    }
838
839    fn invalidate(&self) {
840        // Clone callbacks to avoid holding the borrow while calling them
841        // This prevents re-entrancy issues if a callback triggers another state update
842        let callbacks: Vec<_> = self.inner.with(|rc| {
843            rc.borrow()
844                .invalidate_callbacks
845                .iter()
846                .map(|(_, cb)| Rc::clone(cb))
847                .collect()
848        });
849
850        for callback in callbacks {
851            callback();
852        }
853    }
854}
855
856/// Information about the currently visible items in a lazy list.
857#[derive(Clone, Default, Debug)]
858pub struct LazyListLayoutInfo {
859    /// Information about each visible item.
860    pub visible_items_info: Vec<LazyListItemInfo>,
861
862    /// Total number of items in the list.
863    pub total_items_count: usize,
864
865    /// Raw viewport size reported by parent constraints (before infinite fallback).
866    pub raw_viewport_size: f32,
867
868    /// Whether the viewport was treated as infinite/unbounded.
869    pub is_infinite_viewport: bool,
870
871    /// Size of the viewport in the main axis.
872    pub viewport_size: f32,
873
874    /// Start offset of the viewport (content padding before).
875    pub viewport_start_offset: f32,
876
877    /// End offset of the viewport (content padding after).
878    pub viewport_end_offset: f32,
879
880    /// Content padding before the first item.
881    pub before_content_padding: f32,
882
883    /// Content padding after the last item.
884    pub after_content_padding: f32,
885}
886
887/// Information about a single visible item in a lazy list.
888#[derive(Clone, Debug)]
889pub struct LazyListItemInfo {
890    /// Index of the item in the data source.
891    pub index: usize,
892
893    /// Key of the item.
894    pub key: u64,
895
896    /// Offset of the item from the start of the list content.
897    pub offset: f32,
898
899    /// Size of the item in the main axis.
900    pub size: f32,
901}
902
903/// Test helpers for creating LazyListState without composition context.
904#[cfg(test)]
905pub mod test_helpers {
906    use super::*;
907    use cranpose_core::{DefaultScheduler, Runtime};
908    use std::sync::Arc;
909
910    /// Creates a test runtime and keeps it alive for the duration of the closure.
911    /// Use this to create LazyListState in unit tests.
912    pub fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
913        let _runtime = Runtime::new(Arc::new(DefaultScheduler));
914        f()
915    }
916
917    /// Creates a new LazyListState for testing.
918    /// Must be called within `with_test_runtime`.
919    pub fn new_lazy_list_state() -> LazyListState {
920        new_lazy_list_state_with_position(0, 0.0)
921    }
922
923    /// Creates a new LazyListState for testing with initial position.
924    /// Must be called within `with_test_runtime`.
925    pub fn new_lazy_list_state_with_position(
926        initial_first_visible_item_index: usize,
927        initial_first_visible_item_scroll_offset: f32,
928    ) -> LazyListState {
929        // Create scroll position with reactive fields (matches JC LazyListScrollPosition)
930        let scroll_position = LazyListScrollPosition {
931            index: cranpose_core::mutableStateOf(initial_first_visible_item_index),
932            scroll_offset: cranpose_core::mutableStateOf(initial_first_visible_item_scroll_offset),
933            inner: cranpose_core::mutableStateOf(Rc::new(RefCell::new(ScrollPositionInner {
934                last_known_first_item_key: None,
935                nearest_range_state: NearestRangeState::new(initial_first_visible_item_index),
936            }))),
937        };
938
939        // Non-reactive internal state
940        let inner = cranpose_core::mutableStateOf(Rc::new(RefCell::new(LazyListStateInner {
941            scroll_to_be_consumed: 0.0,
942            pending_scroll_to_index: None,
943            layout_info: LazyListLayoutInfo::default(),
944            invalidate_callbacks: Vec::new(),
945            next_callback_id: 1,
946            has_layout_invalidation_callback: false,
947            total_composed: 0,
948            reuse_count: 0,
949            item_size_cache: std::collections::HashMap::new(),
950            item_size_lru: std::collections::VecDeque::new(),
951            average_item_size: super::super::DEFAULT_ITEM_SIZE_ESTIMATE,
952            total_measured_items: 0,
953            prefetch_scheduler: PrefetchScheduler::new(),
954            prefetch_strategy: PrefetchStrategy::default(),
955            last_scroll_direction: 0.0,
956        })));
957
958        // Reactive state
959        let can_scroll_forward_state = cranpose_core::mutableStateOf(false);
960        let can_scroll_backward_state = cranpose_core::mutableStateOf(false);
961        let stats_state = cranpose_core::mutableStateOf(LazyLayoutStats::default());
962
963        LazyListState {
964            scroll_position,
965            can_scroll_forward_state,
966            can_scroll_backward_state,
967            stats_state,
968            inner,
969        }
970    }
971}
972
973#[cfg(test)]
974mod tests {
975    use super::test_helpers::{new_lazy_list_state, with_test_runtime};
976    use super::{LazyListLayoutInfo, LazyListState};
977    use std::cell::Cell;
978    use std::rc::Rc;
979
980    fn enable_bidirectional_scroll(state: &LazyListState) {
981        state.can_scroll_forward_state.set(true);
982        state.can_scroll_backward_state.set(true);
983    }
984
985    fn mark_scroll_bounds_known(state: &LazyListState) {
986        state.update_layout_info(LazyListLayoutInfo {
987            total_items_count: 10,
988            ..Default::default()
989        });
990    }
991
992    #[test]
993    fn dispatch_scroll_delta_accumulates_same_direction() {
994        with_test_runtime(|| {
995            let state = new_lazy_list_state();
996            enable_bidirectional_scroll(&state);
997
998            state.dispatch_scroll_delta(-12.0);
999            state.dispatch_scroll_delta(-8.0);
1000
1001            assert!((state.peek_scroll_delta() + 20.0).abs() < 0.001);
1002            assert!((state.consume_scroll_delta() + 20.0).abs() < 0.001);
1003            assert_eq!(state.consume_scroll_delta(), 0.0);
1004        });
1005    }
1006
1007    #[test]
1008    fn dispatch_scroll_delta_drops_stale_backlog_on_direction_change() {
1009        with_test_runtime(|| {
1010            let state = new_lazy_list_state();
1011            enable_bidirectional_scroll(&state);
1012
1013            state.dispatch_scroll_delta(-120.0);
1014            state.dispatch_scroll_delta(-30.0);
1015            assert!((state.peek_scroll_delta() + 150.0).abs() < 0.001);
1016
1017            state.dispatch_scroll_delta(18.0);
1018
1019            assert!((state.peek_scroll_delta() - 18.0).abs() < 0.001);
1020            assert!((state.consume_scroll_delta() - 18.0).abs() < 0.001);
1021            assert_eq!(state.consume_scroll_delta(), 0.0);
1022        });
1023    }
1024
1025    #[test]
1026    fn dispatch_scroll_delta_clamps_pending_backlog() {
1027        with_test_runtime(|| {
1028            let state = new_lazy_list_state();
1029            enable_bidirectional_scroll(&state);
1030
1031            state.dispatch_scroll_delta(-1_500.0);
1032            state.dispatch_scroll_delta(-1_500.0);
1033            assert!((state.peek_scroll_delta() + super::MAX_PENDING_SCROLL_DELTA).abs() < 0.001);
1034
1035            state.dispatch_scroll_delta(3_000.0);
1036            assert!((state.peek_scroll_delta() - super::MAX_PENDING_SCROLL_DELTA).abs() < 0.001);
1037        });
1038    }
1039
1040    #[test]
1041    fn dispatch_scroll_delta_skips_invalidate_when_clamped_value_is_unchanged() {
1042        with_test_runtime(|| {
1043            let state = new_lazy_list_state();
1044            enable_bidirectional_scroll(&state);
1045            let invalidations = Rc::new(Cell::new(0u32));
1046            let invalidations_clone = Rc::clone(&invalidations);
1047            state.add_invalidate_callback(Rc::new(move || {
1048                invalidations_clone.set(invalidations_clone.get() + 1);
1049            }));
1050
1051            state.dispatch_scroll_delta(-3_000.0);
1052            assert_eq!(invalidations.get(), 1);
1053            assert!((state.peek_scroll_delta() + super::MAX_PENDING_SCROLL_DELTA).abs() < 0.001);
1054
1055            // Additional same-direction input is clamped to the same pending value.
1056            state.dispatch_scroll_delta(-100.0);
1057            assert_eq!(invalidations.get(), 1);
1058
1059            // Opposite-direction input changes pending and should invalidate again.
1060            state.dispatch_scroll_delta(100.0);
1061            assert_eq!(invalidations.get(), 2);
1062        });
1063    }
1064
1065    #[test]
1066    fn dispatch_scroll_delta_returns_zero_when_forward_is_blocked() {
1067        with_test_runtime(|| {
1068            let state = new_lazy_list_state();
1069            mark_scroll_bounds_known(&state);
1070            state.can_scroll_forward_state.set(false);
1071            state.can_scroll_backward_state.set(true);
1072
1073            let consumed = state.dispatch_scroll_delta(-24.0);
1074
1075            assert_eq!(consumed, 0.0);
1076            assert_eq!(state.peek_scroll_delta(), 0.0);
1077        });
1078    }
1079
1080    #[test]
1081    fn dispatch_scroll_delta_clears_stale_pending_at_forward_edge() {
1082        with_test_runtime(|| {
1083            let state = new_lazy_list_state();
1084            mark_scroll_bounds_known(&state);
1085            enable_bidirectional_scroll(&state);
1086            state.dispatch_scroll_delta(-300.0);
1087            assert!((state.peek_scroll_delta() + 300.0).abs() < 0.001);
1088
1089            state.can_scroll_forward_state.set(false);
1090
1091            let blocked_consumed = state.dispatch_scroll_delta(-10.0);
1092            assert_eq!(blocked_consumed, 0.0);
1093            assert_eq!(state.peek_scroll_delta(), 0.0);
1094
1095            let reverse_consumed = state.dispatch_scroll_delta(12.0);
1096            assert_eq!(reverse_consumed, 12.0);
1097            assert!((state.peek_scroll_delta() - 12.0).abs() < 0.001);
1098        });
1099    }
1100}