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 whether the list is positioned away from its origin without creating
410    /// a reactive subscription.
411    pub fn is_scrolled_non_reactive(&self) -> bool {
412        self.scroll_position.current_index() > 0
413            || self.scroll_position.current_scroll_offset().abs() > 0.001
414            || self
415                .inner
416                .try_with(|rc| {
417                    let inner = rc.borrow();
418                    inner.scroll_to_be_consumed.abs() > 0.001
419                        || inner
420                            .pending_scroll_to_index
421                            .is_some_and(|(index, offset)| index > 0 || offset.abs() > 0.001)
422                })
423                .unwrap_or(false)
424    }
425
426    /// Returns the layout info from the last measure pass.
427    pub fn layout_info(&self) -> LazyListLayoutInfo {
428        self.inner
429            .try_with(|rc| rc.borrow().layout_info.clone())
430            .unwrap_or_default()
431    }
432
433    /// Returns the current item lifecycle statistics.
434    ///
435    /// When called during composition, this creates a reactive subscription
436    /// so that changes to `items_in_use` or `items_in_pool` will trigger recomposition.
437    /// The `total_composed` and `reuse_count` fields are diagnostic and non-reactive.
438    pub fn stats(&self) -> LazyLayoutStats {
439        if !self.stats_state.is_alive() || !self.inner.is_alive() {
440            return LazyLayoutStats::default();
441        }
442        // Read reactive state (creates subscription) and combine with non-reactive counters
443        let reactive = self.stats_state.get();
444        let (total_composed, reuse_count) = self.inner.with(|rc| {
445            let inner = rc.borrow();
446            (inner.total_composed, inner.reuse_count)
447        });
448        LazyLayoutStats {
449            items_in_use: reactive.items_in_use,
450            items_in_pool: reactive.items_in_pool,
451            total_composed,
452            reuse_count,
453        }
454    }
455
456    /// Updates the item lifecycle statistics.
457    ///
458    /// Called by the layout measurement after updating slot pools.
459    /// Triggers recomposition if `items_in_use` or `items_in_pool` changed.
460    pub fn update_stats(&self, items_in_use: usize, items_in_pool: usize) {
461        if !self.stats_state.is_alive() || !self.inner.is_alive() {
462            return;
463        }
464
465        let current = self.stats_state.get_non_reactive();
466
467        // Hysteresis: only trigger reactive update when items_in_use INCREASES
468        // or DECREASES by more than 1. This prevents the 5→4→5→4 oscillation
469        // that happens at boundary conditions during slow upward scroll.
470        //
471        // Rationale:
472        // - Items becoming visible (increase): user should see count update immediately
473        // - Items going off-screen by 1: minor fluctuation, wait for significant change
474        // - Items going off-screen by 2+: significant change, update immediately
475        let should_update_reactive = if items_in_use > current.items_in_use {
476            // Increase: always update (new items visible)
477            true
478        } else if items_in_use < current.items_in_use {
479            // Decrease: only update if by more than 1 (prevents oscillation)
480            current.items_in_use - items_in_use > 1
481        } else {
482            false
483        };
484
485        if should_update_reactive {
486            self.stats_state.set(LazyLayoutStats {
487                items_in_use,
488                items_in_pool,
489                ..current
490            });
491        }
492        // Note: pool-only changes are intentionally not committed to reactive state
493        // to prevent the 5→4→5 oscillation that caused slow upward scroll hang.
494    }
495
496    /// Records that an item was composed (either new or reused).
497    ///
498    /// This updates diagnostic counters in non-reactive state.
499    /// Does NOT trigger recomposition.
500    pub fn record_composition(&self, was_reused: bool) {
501        if !self.inner.is_alive() {
502            return;
503        }
504        self.inner.with(|rc| {
505            let mut inner = rc.borrow_mut();
506            inner.total_composed += 1;
507            if was_reused {
508                inner.reuse_count += 1;
509            }
510        });
511    }
512
513    /// Records the raw scroll delta for prefetch calculations.
514    ///
515    /// Cranpose lazy lists use gesture-style deltas:
516    /// - Negative delta = scrolling forward (content moves up)
517    /// - Positive delta = scrolling backward (content moves down)
518    pub fn record_scroll_direction(&self, delta: f32) {
519        if delta.abs() > 0.001 {
520            if !self.inner.is_alive() {
521                return;
522            }
523            self.inner.with(|rc| {
524                rc.borrow_mut().last_scroll_direction = -delta.signum();
525            });
526        }
527    }
528
529    /// Updates the prefetch queue based on current visible items.
530    /// Should be called after measurement to queue items for pre-composition.
531    pub fn update_prefetch_queue(
532        &self,
533        first_visible_index: usize,
534        last_visible_index: usize,
535        total_items: usize,
536    ) {
537        if !self.inner.is_alive() {
538            return;
539        }
540        self.inner.with(|rc| {
541            let mut inner = rc.borrow_mut();
542            let direction = inner.last_scroll_direction;
543            let strategy = inner.prefetch_strategy.clone();
544            inner.prefetch_scheduler.update(
545                first_visible_index,
546                last_visible_index,
547                total_items,
548                direction,
549                &strategy,
550            );
551        });
552    }
553
554    /// Returns the indices that should be prefetched.
555    /// Consumes the prefetch queue.
556    pub fn take_prefetch_indices(&self) -> Vec<usize> {
557        self.inner
558            .try_with(|rc| {
559                let mut inner = rc.borrow_mut();
560                let mut indices = Vec::new();
561                while let Some(idx) = inner.prefetch_scheduler.next_prefetch() {
562                    indices.push(idx);
563                }
564                indices
565            })
566            .unwrap_or_default()
567    }
568
569    /// Scrolls to the specified item index.
570    ///
571    /// # Arguments
572    /// * `index` - The index of the item to scroll to
573    /// * `scroll_offset` - Additional offset within the item (default 0)
574    pub fn scroll_to_item(&self, index: usize, scroll_offset: f32) {
575        if !self.inner.is_alive() {
576            return;
577        }
578        if lazy_measure_telemetry_enabled() {
579            log::warn!(
580                "[lazy-measure-telemetry] scroll_to_item request index={} offset={:.2}",
581                index,
582                scroll_offset
583            );
584        }
585        // Store pending scroll request
586        self.inner.with(|rc| {
587            rc.borrow_mut().pending_scroll_to_index = Some((index, scroll_offset));
588        });
589
590        // Delegate to scroll_position which handles reactive updates and key clearing
591        self.scroll_position
592            .request_position_and_forget_last_known_key(index, scroll_offset);
593
594        self.invalidate();
595    }
596
597    /// Dispatches a raw scroll delta.
598    ///
599    /// Returns the amount of scroll actually consumed.
600    ///
601    /// This triggers layout invalidation via registered callbacks. The callbacks are
602    /// registered by LazyColumnImpl/LazyRowImpl with schedule_layout_repass(node_id),
603    /// which provides O(subtree) performance instead of O(entire app).
604    pub fn dispatch_scroll_delta(&self, delta: f32) -> f32 {
605        // Guard against stale handles: fling animation frame callbacks can fire
606        // after a tab switch disposes the composition group that owns this state.
607        if !self.inner.is_alive() {
608            return 0.0;
609        }
610        let has_scroll_bounds = self
611            .inner
612            .with(|rc| rc.borrow().layout_info.total_items_count > 0);
613        let pushing_forward = delta < -0.001;
614        let pushing_backward = delta > 0.001;
615        let blocked_by_bounds = has_scroll_bounds
616            && ((pushing_forward && !self.can_scroll_forward())
617                || (pushing_backward && !self.can_scroll_backward()));
618
619        if blocked_by_bounds {
620            let should_invalidate = self.inner.with(|rc| {
621                let mut inner = rc.borrow_mut();
622                let pending_before = inner.scroll_to_be_consumed;
623                // If we're already at an edge, clear stale backlog in the same blocked direction.
624                if pending_before.abs() > 0.001 && pending_before.signum() == delta.signum() {
625                    inner.scroll_to_be_consumed = 0.0;
626                }
627                if lazy_measure_telemetry_enabled() {
628                    log::warn!(
629                        "[lazy-measure-telemetry] dispatch_scroll_delta blocked_by_bounds delta={:.2} pending_before={:.2} pending_after={:.2}",
630                        delta,
631                        pending_before,
632                        inner.scroll_to_be_consumed
633                    );
634                }
635                (inner.scroll_to_be_consumed - pending_before).abs() > 0.001
636            });
637            if should_invalidate {
638                self.invalidate();
639            }
640            return 0.0;
641        }
642
643        let should_invalidate = self.inner.with(|rc| {
644            let mut inner = rc.borrow_mut();
645            let pending_before = inner.scroll_to_be_consumed;
646            let pending = inner.scroll_to_be_consumed;
647            let reverse_input = pending.abs() > 0.001
648                && delta.abs() > 0.001
649                && pending.signum() != delta.signum();
650            if reverse_input {
651                if lazy_measure_telemetry_enabled() {
652                    log::warn!(
653                        "[lazy-measure-telemetry] dispatch_scroll_delta direction_change pending={:.2} new_delta={:.2}",
654                        pending,
655                        delta
656                    );
657                }
658                // When gesture direction reverses, stale unconsumed backlog from the previous
659                // direction causes "snap back" behavior on slow frames. Keep only the latest
660                // direction intent.
661                inner.scroll_to_be_consumed = delta;
662            } else {
663                inner.scroll_to_be_consumed += delta;
664            }
665            inner.scroll_to_be_consumed = inner
666                .scroll_to_be_consumed
667                .clamp(-MAX_PENDING_SCROLL_DELTA, MAX_PENDING_SCROLL_DELTA);
668            if lazy_measure_telemetry_enabled() {
669                log::warn!(
670                    "[lazy-measure-telemetry] dispatch_scroll_delta delta={:.2} pending={:.2}",
671                    delta,
672                    inner.scroll_to_be_consumed
673                );
674            }
675            (inner.scroll_to_be_consumed - pending_before).abs() > 0.001
676        });
677        if should_invalidate {
678            self.invalidate();
679        }
680        delta // Will be adjusted during layout
681    }
682
683    /// Consumes and returns the pending scroll delta.
684    ///
685    /// Called by the layout during measure.
686    pub(crate) fn consume_scroll_delta(&self) -> f32 {
687        self.inner
688            .try_with(|rc| {
689                let mut inner = rc.borrow_mut();
690                let delta = inner.scroll_to_be_consumed;
691                inner.scroll_to_be_consumed = 0.0;
692                delta
693            })
694            .unwrap_or(0.0)
695    }
696
697    /// Peeks at the pending scroll delta without consuming it.
698    ///
699    /// Used for direction inference before measurement consumes the delta.
700    /// This is more accurate than comparing first visible index, especially for:
701    /// - Scrolling within the same item (partial scroll)
702    /// - Variable height items where scroll offset changes without index change
703    pub fn peek_scroll_delta(&self) -> f32 {
704        self.inner
705            .try_with(|rc| rc.borrow().scroll_to_be_consumed)
706            .unwrap_or(0.0)
707    }
708
709    /// Consumes and returns the pending scroll-to-item request.
710    ///
711    /// Called by the layout during measure.
712    pub(crate) fn consume_scroll_to_index(&self) -> Option<(usize, f32)> {
713        self.inner
714            .try_with(|rc| rc.borrow_mut().pending_scroll_to_index.take())
715            .flatten()
716    }
717
718    /// Caches the measured size of an item for scroll estimation.
719    ///
720    /// Uses a HashMap + VecDeque LRU pattern with O(1) insertion and eviction.
721    /// Re-measurement of existing items (uncommon during normal scrolling)
722    /// requires O(n) VecDeque position lookup, but the cache is small (100 items).
723    ///
724    /// # Performance Note
725    /// If profiling shows this as a bottleneck, consider using the `lru` crate
726    /// for O(1) update-in-place operations, or a linked hash map.
727    pub fn cache_item_size(&self, index: usize, size: f32) {
728        use std::collections::hash_map::Entry;
729        if !self.inner.is_alive() {
730            return;
731        }
732        self.inner.with(|rc| {
733            let mut inner = rc.borrow_mut();
734            const MAX_CACHE_SIZE: usize = 100;
735
736            // Check if already in cache (update existing)
737            if let Entry::Occupied(mut entry) = inner.item_size_cache.entry(index) {
738                // Update value and move to back of LRU
739                entry.insert(size);
740                // Remove old position from LRU (O(n) but rare - only on re-measurement)
741                if let Some(pos) = inner.item_size_lru.iter().position(|&k| k == index) {
742                    inner.item_size_lru.remove(pos);
743                }
744                inner.item_size_lru.push_back(index);
745                return;
746            }
747
748            // Evict oldest entries until under limit - O(1) per eviction
749            while inner.item_size_cache.len() >= MAX_CACHE_SIZE {
750                if let Some(oldest) = inner.item_size_lru.pop_front() {
751                    // Only remove if still in cache (may have been updated)
752                    if inner.item_size_cache.remove(&oldest).is_some() {
753                        break; // Removed one entry, now under limit
754                    }
755                } else {
756                    break; // LRU empty, shouldn't happen
757                }
758            }
759
760            // Add new entry
761            inner.item_size_cache.insert(index, size);
762            inner.item_size_lru.push_back(index);
763
764            // Update running average
765            inner.total_measured_items += 1;
766            let n = inner.total_measured_items as f32;
767            inner.average_item_size = inner.average_item_size * ((n - 1.0) / n) + size / n;
768        });
769    }
770
771    /// Gets a cached item size if available.
772    pub fn get_cached_size(&self, index: usize) -> Option<f32> {
773        self.inner
774            .try_with(|rc| rc.borrow().item_size_cache.get(&index).copied())
775            .flatten()
776    }
777
778    /// Returns the running average of measured item sizes.
779    pub fn average_item_size(&self) -> f32 {
780        self.inner
781            .try_with(|rc| rc.borrow().average_item_size)
782            .unwrap_or(super::DEFAULT_ITEM_SIZE_ESTIMATE)
783    }
784
785    /// Returns the current nearest range for optimized key lookup.
786    pub fn nearest_range(&self) -> std::ops::Range<usize> {
787        // Delegate to scroll_position
788        self.scroll_position.nearest_range()
789    }
790
791    /// Updates the scroll position from a layout pass.
792    ///
793    /// Called by the layout after measurement.
794    pub(crate) fn update_scroll_position(
795        &self,
796        first_visible_item_index: usize,
797        first_visible_item_scroll_offset: f32,
798    ) {
799        self.scroll_position.update_from_measure_result(
800            first_visible_item_index,
801            first_visible_item_scroll_offset,
802            None,
803        );
804    }
805
806    /// Updates the scroll position and stores the key of the first visible item.
807    ///
808    /// Called by the layout after measurement to enable scroll position stability.
809    pub(crate) fn update_scroll_position_with_key(
810        &self,
811        first_visible_item_index: usize,
812        first_visible_item_scroll_offset: f32,
813        first_visible_item_key: u64,
814    ) {
815        self.scroll_position.update_from_measure_result(
816            first_visible_item_index,
817            first_visible_item_scroll_offset,
818            Some(first_visible_item_key),
819        );
820    }
821
822    /// Adjusts scroll position if the first visible item was moved due to data changes.
823    ///
824    /// Matches JC's `updateScrollPositionIfTheFirstItemWasMoved`.
825    /// If items were inserted/removed before the current scroll position,
826    /// this finds the item by its key and updates the index accordingly.
827    ///
828    /// Returns the adjusted first visible item index.
829    pub fn update_scroll_position_if_item_moved<F>(
830        &self,
831        new_item_count: usize,
832        get_index_by_key: F,
833    ) -> usize
834    where
835        F: Fn(u64) -> Option<usize>,
836    {
837        // Delegate to scroll_position
838        self.scroll_position
839            .update_if_first_item_moved(new_item_count, get_index_by_key)
840    }
841
842    /// Updates the layout info from a layout pass.
843    pub(crate) fn update_layout_info(&self, info: LazyListLayoutInfo) {
844        if !self.inner.is_alive() {
845            return;
846        }
847        self.inner.with(|rc| rc.borrow_mut().layout_info = info);
848    }
849
850    /// Returns whether we can scroll forward (more items below/right).
851    ///
852    /// When called during composition, this creates a reactive subscription
853    /// so that changes will trigger recomposition.
854    pub fn can_scroll_forward(&self) -> bool {
855        if !self.can_scroll_forward_state.is_alive() {
856            return false;
857        }
858        self.can_scroll_forward_state.get()
859    }
860
861    /// Returns whether we can scroll backward (more items above/left).
862    ///
863    /// When called during composition, this creates a reactive subscription
864    /// so that changes will trigger recomposition.
865    pub fn can_scroll_backward(&self) -> bool {
866        if !self.can_scroll_backward_state.is_alive() {
867            return false;
868        }
869        self.can_scroll_backward_state.get()
870    }
871
872    /// Updates the scroll bounds after layout measurement.
873    ///
874    /// Called by the layout after measurement to update can_scroll_forward/backward.
875    pub(crate) fn update_scroll_bounds(&self) {
876        if !self.inner.is_alive()
877            || !self.can_scroll_forward_state.is_alive()
878            || !self.can_scroll_backward_state.is_alive()
879        {
880            return;
881        }
882        // Compute can_scroll_forward from layout info
883        let can_forward = self.inner.with(|rc| {
884            let inner = rc.borrow();
885            let info = &inner.layout_info;
886            // Use effective viewport end (accounting for after_content_padding)
887            // Without this, lists with padding can report false while still scrollable
888            let viewport_end = info.viewport_size - info.after_content_padding;
889            if let Some(last_visible) = info.visible_items_info.last() {
890                last_visible.index < info.total_items_count.saturating_sub(1)
891                    || (last_visible.offset + last_visible.size) > viewport_end
892            } else {
893                false
894            }
895        });
896
897        // Compute can_scroll_backward from scroll position
898        let can_backward = self.scroll_position.current_index() > 0
899            || self.scroll_position.current_scroll_offset() > 0.0;
900
901        // Update reactive state only if changed
902        if self.can_scroll_forward_state.get_non_reactive() != can_forward {
903            self.can_scroll_forward_state.set(can_forward);
904        }
905        if self.can_scroll_backward_state.get_non_reactive() != can_backward {
906            self.can_scroll_backward_state.set(can_backward);
907        }
908    }
909
910    /// Adds an invalidation callback.
911    pub fn add_invalidate_callback(&self, callback: Rc<dyn Fn()>) -> u64 {
912        if !self.inner.is_alive() {
913            return 0;
914        }
915        self.inner.with(|rc| {
916            let mut inner = rc.borrow_mut();
917            let id = inner.next_callback_id;
918            inner.next_callback_id += 1;
919            inner.invalidate_callbacks.push((id, callback));
920            id
921        })
922    }
923
924    /// Tries to register a layout invalidation callback.
925    ///
926    /// Returns true if the callback was registered, false if one was already registered.
927    /// This prevents duplicate registrations on recomposition.
928    pub fn try_register_layout_callback(&self, callback: Rc<dyn Fn()>) -> bool {
929        if !self.inner.is_alive() {
930            return false;
931        }
932        self.inner.with(|rc| {
933            let mut inner = rc.borrow_mut();
934            if inner.has_layout_invalidation_callback {
935                return false;
936            }
937            inner.has_layout_invalidation_callback = true;
938            let id = inner.next_callback_id;
939            inner.next_callback_id += 1;
940            inner.invalidate_callbacks.push((id, callback));
941            true
942        })
943    }
944
945    /// Removes an invalidation callback.
946    pub fn remove_invalidate_callback(&self, id: u64) {
947        if !self.inner.is_alive() {
948            return;
949        }
950        self.inner.with(|rc| {
951            rc.borrow_mut()
952                .invalidate_callbacks
953                .retain(|(cb_id, _)| *cb_id != id);
954        });
955    }
956
957    fn invalidate(&self) {
958        if !self.inner.is_alive() {
959            return;
960        }
961        // Clone callbacks to avoid holding the borrow while calling them
962        // This prevents re-entrancy issues if a callback triggers another state update
963        let callbacks: Vec<_> = self.inner.with(|rc| {
964            rc.borrow()
965                .invalidate_callbacks
966                .iter()
967                .map(|(_, cb)| Rc::clone(cb))
968                .collect()
969        });
970
971        for callback in callbacks {
972            callback();
973        }
974    }
975}
976
977/// Information about the currently visible items in a lazy list.
978#[derive(Clone, Default, Debug)]
979pub struct LazyListLayoutInfo {
980    /// Information about each visible item.
981    pub visible_items_info: Vec<LazyListItemInfo>,
982
983    /// Total number of items in the list.
984    pub total_items_count: usize,
985
986    /// Raw viewport size reported by parent constraints (before infinite fallback).
987    pub raw_viewport_size: f32,
988
989    /// Whether the viewport was treated as infinite/unbounded.
990    pub is_infinite_viewport: bool,
991
992    /// Size of the viewport in the main axis.
993    pub viewport_size: f32,
994
995    /// Start offset of the viewport (content padding before).
996    pub viewport_start_offset: f32,
997
998    /// End offset of the viewport (content padding after).
999    pub viewport_end_offset: f32,
1000
1001    /// Content padding before the first item.
1002    pub before_content_padding: f32,
1003
1004    /// Content padding after the last item.
1005    pub after_content_padding: f32,
1006}
1007
1008/// Information about a single visible item in a lazy list.
1009#[derive(Clone, Debug)]
1010pub struct LazyListItemInfo {
1011    /// Index of the item in the data source.
1012    pub index: usize,
1013
1014    /// Key of the item.
1015    pub key: u64,
1016
1017    /// Offset of the item from the start of the list content.
1018    pub offset: f32,
1019
1020    /// Size of the item in the main axis.
1021    pub size: f32,
1022}
1023
1024/// Test helpers for creating LazyListState without composition context.
1025#[cfg(test)]
1026pub mod test_helpers {
1027    use super::*;
1028    use cranpose_core::{DefaultScheduler, Runtime};
1029    use std::sync::Arc;
1030
1031    /// Creates a test runtime and keeps it alive for the duration of the closure.
1032    /// Use this to create LazyListState in unit tests.
1033    pub fn with_test_runtime<T>(f: impl FnOnce() -> T) -> T {
1034        let _runtime = Runtime::new(Arc::new(DefaultScheduler));
1035        f()
1036    }
1037
1038    /// Creates a new LazyListState for testing.
1039    /// Must be called within `with_test_runtime`.
1040    pub fn new_lazy_list_state() -> LazyListState {
1041        new_lazy_list_state_with_position(0, 0.0)
1042    }
1043
1044    /// Creates a new LazyListState for testing with initial position.
1045    /// Must be called within `with_test_runtime`.
1046    pub fn new_lazy_list_state_with_position(
1047        initial_first_visible_item_index: usize,
1048        initial_first_visible_item_scroll_offset: f32,
1049    ) -> LazyListState {
1050        // Create scroll position with reactive fields (matches JC LazyListScrollPosition)
1051        let scroll_position = LazyListScrollPosition {
1052            index: cranpose_core::mutableStateOf(initial_first_visible_item_index),
1053            scroll_offset: cranpose_core::mutableStateOf(initial_first_visible_item_scroll_offset),
1054            inner: cranpose_core::mutableStateOf(Rc::new(RefCell::new(ScrollPositionInner {
1055                last_known_first_item_key: None,
1056                nearest_range_state: NearestRangeState::new(initial_first_visible_item_index),
1057            }))),
1058        };
1059
1060        // Non-reactive internal state
1061        let inner = cranpose_core::mutableStateOf(Rc::new(RefCell::new(LazyListStateInner {
1062            scroll_to_be_consumed: 0.0,
1063            pending_scroll_to_index: None,
1064            layout_info: LazyListLayoutInfo::default(),
1065            invalidate_callbacks: Vec::new(),
1066            next_callback_id: 1,
1067            has_layout_invalidation_callback: false,
1068            total_composed: 0,
1069            reuse_count: 0,
1070            item_size_cache: std::collections::HashMap::new(),
1071            item_size_lru: std::collections::VecDeque::new(),
1072            average_item_size: super::super::DEFAULT_ITEM_SIZE_ESTIMATE,
1073            total_measured_items: 0,
1074            prefetch_scheduler: PrefetchScheduler::new(),
1075            prefetch_strategy: PrefetchStrategy::default(),
1076            last_scroll_direction: 0.0,
1077        })));
1078
1079        // Reactive state
1080        let can_scroll_forward_state = cranpose_core::mutableStateOf(false);
1081        let can_scroll_backward_state = cranpose_core::mutableStateOf(false);
1082        let stats_state = cranpose_core::mutableStateOf(LazyLayoutStats::default());
1083
1084        LazyListState {
1085            scroll_position,
1086            can_scroll_forward_state,
1087            can_scroll_backward_state,
1088            stats_state,
1089            inner,
1090        }
1091    }
1092}
1093
1094#[cfg(test)]
1095mod tests {
1096    use super::test_helpers::{new_lazy_list_state, with_test_runtime};
1097    use super::{LazyListLayoutInfo, LazyListState};
1098    use cranpose_core::{location_key, Composition, MemoryApplier};
1099    use std::cell::Cell;
1100    use std::rc::Rc;
1101
1102    fn enable_bidirectional_scroll(state: &LazyListState) {
1103        state.can_scroll_forward_state.set(true);
1104        state.can_scroll_backward_state.set(true);
1105    }
1106
1107    fn mark_scroll_bounds_known(state: &LazyListState) {
1108        state.update_layout_info(LazyListLayoutInfo {
1109            total_items_count: 10,
1110            ..Default::default()
1111        });
1112    }
1113
1114    #[test]
1115    fn dispatch_scroll_delta_accumulates_same_direction() {
1116        with_test_runtime(|| {
1117            let state = new_lazy_list_state();
1118            enable_bidirectional_scroll(&state);
1119
1120            state.dispatch_scroll_delta(-12.0);
1121            state.dispatch_scroll_delta(-8.0);
1122
1123            assert!((state.peek_scroll_delta() + 20.0).abs() < 0.001);
1124            assert!((state.consume_scroll_delta() + 20.0).abs() < 0.001);
1125            assert_eq!(state.consume_scroll_delta(), 0.0);
1126        });
1127    }
1128
1129    #[test]
1130    fn dispatch_scroll_delta_drops_stale_backlog_on_direction_change() {
1131        with_test_runtime(|| {
1132            let state = new_lazy_list_state();
1133            enable_bidirectional_scroll(&state);
1134
1135            state.dispatch_scroll_delta(-120.0);
1136            state.dispatch_scroll_delta(-30.0);
1137            assert!((state.peek_scroll_delta() + 150.0).abs() < 0.001);
1138
1139            state.dispatch_scroll_delta(18.0);
1140
1141            assert!((state.peek_scroll_delta() - 18.0).abs() < 0.001);
1142            assert!((state.consume_scroll_delta() - 18.0).abs() < 0.001);
1143            assert_eq!(state.consume_scroll_delta(), 0.0);
1144        });
1145    }
1146
1147    #[test]
1148    fn dispatch_scroll_delta_clamps_pending_backlog() {
1149        with_test_runtime(|| {
1150            let state = new_lazy_list_state();
1151            enable_bidirectional_scroll(&state);
1152
1153            state.dispatch_scroll_delta(-1_500.0);
1154            state.dispatch_scroll_delta(-1_500.0);
1155            assert!((state.peek_scroll_delta() + super::MAX_PENDING_SCROLL_DELTA).abs() < 0.001);
1156
1157            state.dispatch_scroll_delta(3_000.0);
1158            assert!((state.peek_scroll_delta() - super::MAX_PENDING_SCROLL_DELTA).abs() < 0.001);
1159        });
1160    }
1161
1162    #[test]
1163    fn dispatch_scroll_delta_skips_invalidate_when_clamped_value_is_unchanged() {
1164        with_test_runtime(|| {
1165            let state = new_lazy_list_state();
1166            enable_bidirectional_scroll(&state);
1167            let invalidations = Rc::new(Cell::new(0u32));
1168            let invalidations_clone = Rc::clone(&invalidations);
1169            state.add_invalidate_callback(Rc::new(move || {
1170                invalidations_clone.set(invalidations_clone.get() + 1);
1171            }));
1172
1173            state.dispatch_scroll_delta(-3_000.0);
1174            assert_eq!(invalidations.get(), 1);
1175            assert!((state.peek_scroll_delta() + super::MAX_PENDING_SCROLL_DELTA).abs() < 0.001);
1176
1177            // Additional same-direction input is clamped to the same pending value.
1178            state.dispatch_scroll_delta(-100.0);
1179            assert_eq!(invalidations.get(), 1);
1180
1181            // Opposite-direction input changes pending and should invalidate again.
1182            state.dispatch_scroll_delta(100.0);
1183            assert_eq!(invalidations.get(), 2);
1184        });
1185    }
1186
1187    #[test]
1188    fn dispatch_scroll_delta_returns_zero_when_forward_is_blocked() {
1189        with_test_runtime(|| {
1190            let state = new_lazy_list_state();
1191            mark_scroll_bounds_known(&state);
1192            state.can_scroll_forward_state.set(false);
1193            state.can_scroll_backward_state.set(true);
1194
1195            let consumed = state.dispatch_scroll_delta(-24.0);
1196
1197            assert_eq!(consumed, 0.0);
1198            assert_eq!(state.peek_scroll_delta(), 0.0);
1199        });
1200    }
1201
1202    #[test]
1203    fn equality_does_not_deref_released_inner_state() {
1204        let mut composition = Composition::new(MemoryApplier::new());
1205        let key = location_key(file!(), line!(), column!());
1206
1207        let mut first = None;
1208        composition
1209            .render(key, || {
1210                first = Some(super::remember_lazy_list_state());
1211            })
1212            .expect("initial render");
1213        let first = first.expect("first lazy state");
1214
1215        composition
1216            .render(key, || {})
1217            .expect("dispose first lazy state");
1218        assert!(
1219            !first.inner.is_alive(),
1220            "expected first lazy state to be released after disposal"
1221        );
1222
1223        let mut second = None;
1224        composition
1225            .render(key, || {
1226                second = Some(super::remember_lazy_list_state());
1227            })
1228            .expect("second render");
1229        let second = second.expect("second lazy state");
1230
1231        assert!(
1232            first != second,
1233            "released lazy state handle must compare by identity without panicking"
1234        );
1235    }
1236
1237    #[test]
1238    fn released_lazy_list_state_scroll_position_methods_do_not_panic() {
1239        let mut composition = Composition::new(MemoryApplier::new());
1240        let key = location_key(file!(), line!(), column!());
1241
1242        let mut released = None;
1243        composition
1244            .render(key, || {
1245                released = Some(super::remember_lazy_list_state());
1246            })
1247            .expect("initial render");
1248        let released = released.expect("lazy list state");
1249
1250        composition
1251            .render(key, || {})
1252            .expect("dispose lazy list state");
1253        assert!(
1254            !released.inner.is_alive(),
1255            "expected lazy list state to be released after disposal"
1256        );
1257
1258        assert_eq!(released.first_visible_item_index(), 0);
1259        assert_eq!(released.first_visible_item_scroll_offset(), 0.0);
1260        assert_eq!(released.nearest_range(), 0..0);
1261        assert_eq!(
1262            released.update_scroll_position_if_item_moved(10, |_| Some(0)),
1263            0
1264        );
1265        released.update_scroll_position(3, 12.0);
1266        released.update_scroll_position_with_key(3, 12.0, 42);
1267        released.update_scroll_bounds();
1268    }
1269
1270    #[test]
1271    fn dispatch_scroll_delta_clears_stale_pending_at_forward_edge() {
1272        with_test_runtime(|| {
1273            let state = new_lazy_list_state();
1274            mark_scroll_bounds_known(&state);
1275            enable_bidirectional_scroll(&state);
1276            state.dispatch_scroll_delta(-300.0);
1277            assert!((state.peek_scroll_delta() + 300.0).abs() < 0.001);
1278
1279            state.can_scroll_forward_state.set(false);
1280
1281            let blocked_consumed = state.dispatch_scroll_delta(-10.0);
1282            assert_eq!(blocked_consumed, 0.0);
1283            assert_eq!(state.peek_scroll_delta(), 0.0);
1284
1285            let reverse_consumed = state.dispatch_scroll_delta(12.0);
1286            assert_eq!(reverse_consumed, 12.0);
1287            assert!((state.peek_scroll_delta() - 12.0).abs() < 0.001);
1288        });
1289    }
1290
1291    #[test]
1292    fn negative_scroll_delta_prefetches_forward_items() {
1293        with_test_runtime(|| {
1294            let state = new_lazy_list_state();
1295            state.dispatch_scroll_delta(-24.0);
1296            state.record_scroll_direction(state.peek_scroll_delta());
1297            state.update_prefetch_queue(10, 15, 100);
1298
1299            assert_eq!(state.take_prefetch_indices(), vec![16, 17]);
1300        });
1301    }
1302}