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