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