Skip to main content

yew_virtual/core/
virtualizer.rs

1use std::cell::RefCell;
2use std::collections::HashMap;
3
4use crate::core::measure_item_outcome::MeasureItemOutcome;
5use crate::core::measurement_cache::MeasurementCache;
6use crate::core::range_calculator::RangeCalculator;
7use crate::core::scroll_alignment::ScrollAlignment;
8use crate::core::scroll_behavior::ScrollBehavior;
9use crate::core::scroll_reconcile_action::ScrollReconcileAction;
10use crate::core::scroll_state::ScrollState;
11use crate::core::scroll_to_options::ScrollToOptions;
12use crate::core::virtual_item::VirtualItem;
13use crate::core::virtual_key::VirtualKey;
14use crate::core::virtualizer_error::VirtualizerError;
15use crate::core::virtualizer_options::VirtualizerOptions;
16use crate::core::visible_range::VisibleRange;
17
18/// The core virtualization engine.
19///
20/// Maintains all state needed for virtualization: measurements cache,
21/// visible ranges, scroll position, lane assignments, and scroll state.
22/// It produces `VirtualItem` metadata that Yew components use to render
23/// only the items within (and near) the visible viewport.
24///
25/// This engine is headless -- it does not interact with the DOM directly.
26/// Instead, it accepts inputs (scroll offset, container size, measurements)
27/// and produces outputs (virtual items, total size, scroll targets).
28#[derive(Debug, Clone)]
29pub struct Virtualizer {
30    /// The current configuration options.
31    options: VirtualizerOptions,
32
33    /// Precomputed measurements for all items.
34    measurements_cache: Vec<VirtualItem>,
35
36    /// Cache for dynamically measured item sizes keyed by VirtualKey.
37    item_size_cache: MeasurementCache,
38
39    /// Lane assignments cache mapping item index to lane.
40    lane_assignments: HashMap<usize, usize>,
41
42    /// Indices of items with pending measurement changes.
43    pending_measured_cache_indexes: Vec<usize>,
44
45    /// Previous lanes value for detecting lane count changes.
46    prev_lanes: Option<usize>,
47
48    /// Current scroll offset in pixels.
49    scroll_offset: f64,
50
51    /// Current container viewport size in pixels.
52    container_size: f64,
53
54    /// Start index of the current visible range (inclusive).
55    range_start: usize,
56
57    /// End index of the current visible range (inclusive).
58    range_end: usize,
59
60    /// Total scrollable size in pixels.
61    total_size: f64,
62
63    /// Whether the user is currently scrolling.
64    is_scrolling: bool,
65
66    /// The direction of the last scroll event.
67    ///
68    /// True means forward (scrolling down/right), false means backward.
69    /// None means no scroll has occurred.
70    scroll_forward: Option<bool>,
71
72    /// Accumulated scroll adjustments for maintaining position during
73    /// item resizes above the viewport.
74    scroll_adjustments: f64,
75
76    /// Active programmatic scroll state, if any.
77    scroll_state: Option<ScrollState>,
78
79    /// Bumps when measurements or layout affecting item positions change.
80    measurement_version: u64,
81
82    /// Memoized virtual item list for repeated reads within the same layout epoch.
83    virtual_items_cache: RefCell<Option<(u64, Vec<VirtualItem>)>>,
84}
85
86impl Virtualizer {
87    /// Creates an empty virtualizer with zero items and no scrollable content.
88    ///
89    /// This constructor is infallible and always returns a valid instance.
90    /// It is intended as a safe fallback when normal construction fails,
91    /// ensuring no `unwrap()`, `expect()`, or `panic!()` is needed.
92    ///
93    /// # Returns
94    ///
95    /// - `Virtualizer`: An empty virtualizer that produces no virtual items.
96    pub fn empty() -> Self {
97        // Construct the virtualizer directly without validation.
98        Self {
99            options: VirtualizerOptions::default(),
100            measurements_cache: Vec::new(),
101            item_size_cache: MeasurementCache::new(1.0),
102            lane_assignments: HashMap::new(),
103            pending_measured_cache_indexes: Vec::new(),
104            prev_lanes: None,
105            scroll_offset: 0.0,
106            container_size: 0.0,
107            range_start: 0,
108            range_end: 0,
109            total_size: 0.0,
110            is_scrolling: false,
111            scroll_forward: None,
112            scroll_adjustments: 0.0,
113            scroll_state: None,
114            measurement_version: 0,
115            virtual_items_cache: RefCell::new(None),
116        }
117    }
118
119    /// Creates a new virtualizer with the given options.
120    ///
121    /// Validates configuration, initializes internal arrays, and
122    /// computes the initial layout.
123    ///
124    /// # Parameters
125    ///
126    /// - `options`: Configuration for the virtualizer.
127    ///
128    /// # Returns
129    ///
130    /// - `Ok(Virtualizer)`: A fully initialized virtualizer.
131    /// - `Err(VirtualizerError)`: If configuration is invalid.
132    ///
133    /// # Errors
134    ///
135    /// - Returns `InvalidItemSize` if the base size is zero or negative.
136    /// - Returns `InvalidConfiguration` if padding, gap, or lanes is invalid.
137    pub fn new(options: VirtualizerOptions) -> Result<Self, VirtualizerError> {
138        // Validate item size.
139        let base_size = options.item_size_mode.base_size();
140        if base_size <= 0.0 || base_size.is_nan() || base_size.is_infinite() {
141            return Err(VirtualizerError::InvalidItemSize(format!(
142                "Base size must be positive and finite, got {}",
143                base_size
144            )));
145        }
146
147        // Validate padding values.
148        if options.padding_start < 0.0 || options.padding_end < 0.0 {
149            return Err(VirtualizerError::InvalidConfiguration(
150                "Padding values must be non-negative".to_string(),
151            ));
152        }
153
154        // Validate gap value.
155        if options.gap < 0.0 {
156            return Err(VirtualizerError::InvalidConfiguration(
157                "Gap must be non-negative".to_string(),
158            ));
159        }
160
161        // Validate lanes.
162        if options.lanes == 0 {
163            return Err(VirtualizerError::InvalidConfiguration(
164                "Lanes must be at least 1".to_string(),
165            ));
166        }
167
168        // Initialize the measurement cache with the base size estimate.
169        let item_size_cache = MeasurementCache::new(base_size);
170
171        // Resolve the container size from options or initial rect.
172        let container_size = options.container_size.unwrap_or_else(|| {
173            if options.scroll_direction
174                == crate::core::scroll_direction::ScrollDirection::Horizontal
175            {
176                options.initial_rect.width
177            } else {
178                options.initial_rect.height
179            }
180        });
181
182        // Resolve the initial scroll offset from callback or scalar option.
183        let scroll_offset = if let Some(ref f) = options.initial_offset_fn {
184            f()
185        } else {
186            options.initial_offset
187        };
188
189        // Build the virtualizer.
190        let mut virt = Self {
191            options,
192            measurements_cache: Vec::new(),
193            item_size_cache,
194            lane_assignments: HashMap::new(),
195            pending_measured_cache_indexes: Vec::new(),
196            prev_lanes: None,
197            scroll_offset,
198            container_size,
199            range_start: 0,
200            range_end: 0,
201            total_size: 0.0,
202            is_scrolling: false,
203            scroll_forward: None,
204            scroll_adjustments: 0.0,
205            scroll_state: None,
206            measurement_version: 0,
207            virtual_items_cache: RefCell::new(None),
208        };
209
210        // Compute the initial measurements.
211        virt.rebuild_measurements();
212
213        // Compute the initial range.
214        virt.recalculate_range();
215
216        Ok(virt)
217    }
218
219    /// Updates all options at runtime without recreating the virtualizer.
220    ///
221    /// Validates the new configuration and recalculates layout. Measurement
222    /// state is preserved across option changes.
223    ///
224    /// # Parameters
225    ///
226    /// - `options`: The new configuration options.
227    ///
228    /// # Returns
229    ///
230    /// - `Ok(())`: If options were applied successfully.
231    /// - `Err(VirtualizerError)`: If the new configuration is invalid.
232    ///
233    /// # Errors
234    ///
235    /// - Returns `InvalidItemSize` if the base size is zero or negative.
236    /// - Returns `InvalidConfiguration` if padding, gap, or lanes is invalid.
237    pub fn set_options(&mut self, options: VirtualizerOptions) -> Result<(), VirtualizerError> {
238        // Validate item size.
239        let base_size = options.item_size_mode.base_size();
240        if base_size <= 0.0 || base_size.is_nan() || base_size.is_infinite() {
241            return Err(VirtualizerError::InvalidItemSize(format!(
242                "Base size must be positive and finite, got {}",
243                base_size
244            )));
245        }
246
247        // Validate padding values.
248        if options.padding_start < 0.0 || options.padding_end < 0.0 {
249            return Err(VirtualizerError::InvalidConfiguration(
250                "Padding values must be non-negative".to_string(),
251            ));
252        }
253
254        // Validate gap value.
255        if options.gap < 0.0 {
256            return Err(VirtualizerError::InvalidConfiguration(
257                "Gap must be non-negative".to_string(),
258            ));
259        }
260
261        // Validate lanes.
262        if options.lanes == 0 {
263            return Err(VirtualizerError::InvalidConfiguration(
264                "Lanes must be at least 1".to_string(),
265            ));
266        }
267
268        // Detect lane count changes.
269        if let Some(prev) = self.prev_lanes {
270            if prev != options.lanes {
271                // Clear lane assignments and size cache on lane change.
272                self.lane_assignments.clear();
273                self.item_size_cache.clear(base_size);
274                self.measurements_cache.clear();
275                self.pending_measured_cache_indexes.clear();
276            }
277        }
278
279        // Store the previous lanes for future detection.
280        self.prev_lanes = Some(options.lanes);
281
282        // Apply the new options.
283        self.options = options;
284
285        // Rebuild measurements and range.
286        self.rebuild_measurements();
287        self.recalculate_range();
288
289        self.notify_change();
290
291        Ok(())
292    }
293
294    /// Resolves the estimated size for a given item index.
295    ///
296    /// # Parameters
297    ///
298    /// - `index`: The item index.
299    ///
300    /// # Returns
301    ///
302    /// - `f64`: The estimated size for this item.
303    fn estimate_size_for_index(&self, index: usize) -> f64 {
304        // Use the per-index callback if provided.
305        if let Some(ref estimate_fn) = self.options.estimate_size {
306            return estimate_fn(index);
307        }
308
309        // Fall back to the base size from item_size_mode.
310        self.options.item_size_mode.base_size()
311    }
312
313    /// Resolves the key for a given item index.
314    ///
315    /// # Parameters
316    ///
317    /// - `index`: The item index.
318    ///
319    /// # Returns
320    ///
321    /// - `VirtualKey`: The key for this item.
322    fn get_key_for_index(&self, index: usize) -> VirtualKey {
323        // Use the custom key extractor if provided.
324        if let Some(ref key_fn) = self.options.get_item_key {
325            return key_fn(index);
326        }
327
328        // Default to using the index as the key.
329        VirtualKey::Index(index)
330    }
331
332    /// Rebuilds the full measurements cache from scratch or incrementally.
333    fn rebuild_measurements(&mut self) {
334        // If disabled, clear everything.
335        if !self.options.enabled {
336            self.measurements_cache.clear();
337            self.item_size_cache
338                .clear(self.options.item_size_mode.base_size());
339            self.lane_assignments.clear();
340            self.total_size = 0.0;
341            self.measurement_version = self.measurement_version.wrapping_add(1);
342            self.virtual_items_cache.borrow_mut().take();
343            return;
344        }
345
346        let count = self.options.item_count;
347        let lanes = self.options.lanes;
348        let padding_start = self.options.padding_start;
349        let scroll_margin = self.options.scroll_margin;
350        let gap = self.options.gap;
351
352        // Clean up stale lane cache entries when count decreases.
353        if self.lane_assignments.len() > count {
354            self.lane_assignments.retain(|&index, _| index < count);
355        }
356
357        // Determine the minimum index to rebuild from.
358        let min = if self.pending_measured_cache_indexes.is_empty() {
359            0
360        } else {
361            self.pending_measured_cache_indexes
362                .iter()
363                .copied()
364                .min()
365                .unwrap_or(0)
366        };
367        self.pending_measured_cache_indexes.clear();
368
369        // Populate from initial_measurements_cache if we have no cache yet.
370        if self.measurements_cache.is_empty() && !self.options.initial_measurements_cache.is_empty()
371        {
372            self.measurements_cache = self.options.initial_measurements_cache.clone();
373            for item in &self.measurements_cache {
374                let _ = self.item_size_cache.record(item.key.clone(), item.size);
375            }
376        }
377
378        // Truncate if min is less than current cache length.
379        if min < self.measurements_cache.len() {
380            self.measurements_cache.truncate(min);
381        }
382
383        // Track last item index per lane for O(1) lookup.
384        let mut lane_last_index: Vec<Option<usize>> = vec![None; lanes];
385
386        // Initialize from existing measurements (before min).
387        for m in 0..self.measurements_cache.len().min(min) {
388            if let Some(item) = self.measurements_cache.get(m) {
389                if item.lane < lanes {
390                    lane_last_index[item.lane] = Some(m);
391                }
392            }
393        }
394
395        // Build measurements for indices from min to count.
396        for i in min..count {
397            let key = self.get_key_for_index(i);
398
399            // Check for cached lane assignment.
400            let cached_lane = self.lane_assignments.get(&i).copied();
401
402            let lane: usize;
403            let start: f64;
404
405            if let Some(cl) = cached_lane {
406                if lanes > 1 {
407                    // Use cached lane with O(1) lookup for previous item in same lane.
408                    lane = cl;
409                    let prev_index = lane_last_index.get(lane).copied().flatten();
410                    let prev_end = prev_index
411                        .and_then(|pi| self.measurements_cache.get(pi))
412                        .map(|item| item.end);
413                    start = prev_end
414                        .map(|e| e + gap)
415                        .unwrap_or(padding_start + scroll_margin);
416                } else {
417                    // Single lane: use previous item.
418                    lane = 0;
419                    let prev_item = if i > 0 {
420                        self.measurements_cache.get(i - 1)
421                    } else {
422                        None
423                    };
424                    start = prev_item
425                        .map(|item| item.end + gap)
426                        .unwrap_or(padding_start + scroll_margin);
427                }
428            } else {
429                // No cache: find the shortest lane.
430                if lanes == 1 {
431                    lane = 0;
432                    let prev_item = if i > 0 {
433                        self.measurements_cache.get(i - 1)
434                    } else {
435                        None
436                    };
437                    start = prev_item
438                        .map(|item| item.end + gap)
439                        .unwrap_or(padding_start + scroll_margin);
440                } else {
441                    // Find the lane with the smallest end value.
442                    let furthest = self.get_furthest_measurement(i);
443                    start = furthest
444                        .as_ref()
445                        .map(|item| item.end + gap)
446                        .unwrap_or(padding_start + scroll_margin);
447                    lane = furthest.as_ref().map(|item| item.lane).unwrap_or(i % lanes);
448
449                    // Cache the lane assignment for multi-lane.
450                    if lanes > 1 {
451                        self.lane_assignments.insert(i, lane);
452                    }
453                }
454            }
455
456            // Resolve the size from cache or estimate.
457            let measured_size = self.item_size_cache.get(&key);
458            let size = measured_size.unwrap_or_else(|| self.estimate_size_for_index(i));
459
460            // Build the virtual item.
461            let item = VirtualItem::with_key_and_lane(i, size, start, key, lane);
462
463            // Store or update in measurements cache.
464            if i < self.measurements_cache.len() {
465                self.measurements_cache[i] = item;
466            } else {
467                self.measurements_cache.push(item);
468            }
469
470            // Update lane's last item index.
471            if lane < lanes {
472                lane_last_index[lane] = Some(i);
473            }
474        }
475
476        // Truncate any excess items beyond count.
477        self.measurements_cache.truncate(count);
478
479        // Recompute total size.
480        self.recompute_total_size();
481    }
482
483    /// Finds the measurement in the shortest lane for multi-lane assignment.
484    ///
485    /// # Parameters
486    ///
487    /// - `index`: The index to find the furthest measurement before.
488    ///
489    /// # Returns
490    ///
491    /// - `Option<VirtualItem>`: The measurement with the smallest end value.
492    fn get_furthest_measurement(&self, index: usize) -> Option<VirtualItem> {
493        let lanes = self.options.lanes;
494        let mut furthest_found: HashMap<usize, bool> = HashMap::new();
495        let mut furthest_measurements: HashMap<usize, VirtualItem> = HashMap::new();
496
497        // Walk backwards through measurements to find the shortest lane.
498        let mut m = index;
499        while m > 0 {
500            m -= 1;
501            if let Some(measurement) = self.measurements_cache.get(m) {
502                if furthest_found.contains_key(&measurement.lane) {
503                    continue;
504                }
505
506                let prev = furthest_measurements.get(&measurement.lane);
507                if prev.is_none() || measurement.end > prev.map_or(0.0, |p| p.end) {
508                    furthest_measurements.insert(measurement.lane, measurement.clone());
509                } else if measurement.end < prev.map_or(0.0, |p| p.end) {
510                    furthest_found.insert(measurement.lane, true);
511                }
512
513                if furthest_found.len() == lanes {
514                    break;
515                }
516            }
517        }
518
519        // Return the lane with the smallest end value if all lanes are covered.
520        if furthest_measurements.len() == lanes {
521            furthest_measurements
522                .values()
523                .min_by(|a, b| {
524                    a.end
525                        .partial_cmp(&b.end)
526                        .unwrap_or(std::cmp::Ordering::Equal)
527                        .then(a.index.cmp(&b.index))
528                })
529                .cloned()
530        } else {
531            None
532        }
533    }
534
535    /// Recomputes the total scrollable size from measurements.
536    fn recompute_total_size(&mut self) {
537        let measurements = &self.measurements_cache;
538        let lanes = self.options.lanes;
539
540        // Find the maximum end value.
541        let end = if measurements.is_empty() {
542            self.options.padding_start
543        } else if lanes == 1 {
544            measurements.last().map_or(0.0, |item| item.end)
545        } else {
546            // Find the maximum end value across all lanes.
547            let mut end_by_lane: Vec<Option<f64>> = vec![None; lanes];
548            let mut idx = measurements.len();
549            while idx > 0 && end_by_lane.iter().any(|v| v.is_none()) {
550                idx -= 1;
551                if let Some(item) = measurements.get(idx) {
552                    if item.lane < lanes && end_by_lane[item.lane].is_none() {
553                        end_by_lane[item.lane] = Some(item.end);
554                    }
555                }
556            }
557            end_by_lane.iter().filter_map(|v| *v).fold(0.0f64, f64::max)
558        };
559
560        // Total = end - scroll_margin + padding_end.
561        self.total_size = (end - self.options.scroll_margin + self.options.padding_end).max(0.0);
562
563        // Invalidate memoized virtual items after measurement rebuild.
564        self.measurement_version = self.measurement_version.wrapping_add(1);
565        self.virtual_items_cache.borrow_mut().take();
566    }
567
568    /// Computes a fingerprint for the virtual items memoization cache.
569    fn virtual_items_cache_key(&self) -> u64 {
570        // Mix range, count, layout epoch, and enabled flag into one key.
571        let mut h: u64 = self.range_start as u64;
572        h ^= (self.range_end as u64).wrapping_shl(12);
573        h ^= (self.options.item_count as u64).wrapping_shl(24);
574        h ^= self.measurement_version.wrapping_shl(32);
575        if !self.options.enabled {
576            h ^= 1u64 << 63;
577        }
578        h
579    }
580
581    /// Notifies the optional `on_change` callback.
582    fn notify_change(&self) {
583        // Invoke the consumer hook when virtualizer-visible state changes.
584        if let Some(ref cb) = self.options.on_change {
585            cb();
586        }
587    }
588
589    /// Updates the scroll offset and recalculates the visible range.
590    ///
591    /// This is the primary method called during scroll events.
592    ///
593    /// # Parameters
594    ///
595    /// - `scroll_offset`: The new scroll position in pixels.
596    /// - `is_scrolling`: Whether the user is actively scrolling.
597    pub fn update_scroll_offset(&mut self, scroll_offset: f64, is_scrolling: bool) {
598        // Track scroll direction.
599        if is_scrolling {
600            self.scroll_forward = Some(scroll_offset > self.scroll_offset);
601        } else {
602            self.scroll_forward = None;
603        }
604
605        // Update scrolling state.
606        self.is_scrolling = is_scrolling;
607
608        // Reset scroll adjustments on new offset.
609        self.scroll_adjustments = 0.0;
610
611        // Store the new scroll offset.
612        self.scroll_offset = scroll_offset;
613
614        // Recalculate the visible range.
615        self.recalculate_range();
616
617        // Drop memoized items because the visible range may have changed.
618        self.virtual_items_cache.borrow_mut().take();
619        self.notify_change();
620    }
621
622    /// Updates the container viewport size and recalculates the visible range.
623    ///
624    /// Called when the scroll container is resized.
625    ///
626    /// # Parameters
627    ///
628    /// - `container_size`: The new viewport size in pixels.
629    pub fn update_container_size(&mut self, container_size: f64) {
630        // Store the new container size, ensuring non-negative.
631        self.container_size = container_size.max(0.0);
632
633        // Recalculate the visible range with the new size.
634        self.recalculate_range();
635
636        self.virtual_items_cache.borrow_mut().take();
637        self.notify_change();
638    }
639
640    /// Records a measurement for a specific item and recalculates layout if changed.
641    ///
642    /// # Parameters
643    ///
644    /// - `index`: The index of the item that was measured.
645    /// - `size`: The measured size in pixels.
646    ///
647    /// # Returns
648    ///
649    /// - `Ok(MeasureItemOutcome)`: Layout change flag and DOM scroll compensation.
650    /// - `Err(VirtualizerError)`: If the index is out of bounds or size is invalid.
651    ///
652    /// # Errors
653    ///
654    /// - Returns `IndexOutOfBounds` if index >= item_count.
655    /// - Returns `MeasurementError` if size is invalid.
656    pub fn measure_item(
657        &mut self,
658        index: usize,
659        size: f64,
660    ) -> Result<MeasureItemOutcome, VirtualizerError> {
661        // Validate the index.
662        if index >= self.options.item_count {
663            return Err(VirtualizerError::IndexOutOfBounds {
664                requested: index,
665                total: self.options.item_count,
666            });
667        }
668
669        // Get the key for this item.
670        let key = self.get_key_for_index(index);
671
672        // Check the existing cached size.
673        let existing_size = self
674            .item_size_cache
675            .get(&key)
676            .or_else(|| self.measurements_cache.get(index).map(|item| item.size));
677
678        let prior = existing_size.unwrap_or(size);
679        let delta = size - prior;
680
681        // Record the measurement in the cache.
682        let changed = self.item_size_cache.record(key, size)?;
683
684        let mut scroll_compensation = 0.0;
685
686        // Recalculate layout if the measurement changed.
687        if changed {
688            let smooth_active = self
689                .scroll_state
690                .as_ref()
691                .map(|s| s.behavior == ScrollBehavior::Smooth)
692                .unwrap_or(false);
693
694            let allow_resize_adjust =
695                !smooth_active || self.options.adjust_scroll_on_resize_during_smooth_scroll;
696
697            let mut should_adjust = true;
698            if let Some(ref cb) = self
699                .options
700                .should_adjust_scroll_position_on_item_size_change
701            {
702                should_adjust = cb(index, prior, size);
703            }
704
705            // Adjust scroll position if the item is above the viewport.
706            if allow_resize_adjust && should_adjust {
707                if let Some(item) = self.measurements_cache.get(index) {
708                    if item.start < self.scroll_offset + self.scroll_adjustments {
709                        self.scroll_adjustments += delta;
710                        scroll_compensation = delta;
711                    }
712                }
713            }
714
715            // Mark this index as pending for incremental rebuild.
716            self.pending_measured_cache_indexes.push(index);
717
718            // Rebuild measurements and range.
719            self.rebuild_measurements();
720            self.recalculate_range();
721            self.notify_change();
722        }
723
724        Ok(MeasureItemOutcome {
725            layout_changed: changed,
726            scroll_compensation,
727        })
728    }
729
730    /// Updates the total item count and recalculates layout.
731    ///
732    /// Handles dataset size changes (insertions, removals) without
733    /// full reinitialization.
734    ///
735    /// # Parameters
736    ///
737    /// - `new_count`: The new total number of items.
738    pub fn update_item_count(&mut self, new_count: usize) {
739        // Update the options.
740        self.options.item_count = new_count;
741
742        // Rebuild measurements and range.
743        self.rebuild_measurements();
744        self.recalculate_range();
745
746        self.notify_change();
747    }
748
749    /// Calculates the scroll offset needed to bring a specific item into view.
750    ///
751    /// Accounts for scroll padding and alignment. Does not actually scroll.
752    ///
753    /// # Parameters
754    ///
755    /// - `index`: The index of the target item.
756    /// - `align`: How to align the item within the viewport.
757    ///
758    /// # Returns
759    ///
760    /// - `Option<(f64, ScrollAlignment)>`: The target scroll offset and resolved alignment,
761    ///   or None if the index has no measurement.
762    pub fn get_offset_for_index(
763        &self,
764        index: usize,
765        align: ScrollAlignment,
766    ) -> Option<(f64, ScrollAlignment)> {
767        // Clamp the index to valid range.
768        let clamped_index = if self.options.item_count == 0 {
769            return None;
770        } else {
771            index.min(self.options.item_count - 1)
772        };
773
774        // Look up the item measurement.
775        let item = self.measurements_cache.get(clamped_index)?;
776
777        let size = self.container_size;
778        let scroll_offset = self.scroll_offset;
779
780        // Resolve auto alignment.
781        let mut resolved_align = align;
782        if resolved_align == ScrollAlignment::Auto {
783            if item.end >= scroll_offset + size - self.options.scroll_padding_end {
784                resolved_align = ScrollAlignment::End;
785            } else if item.start <= scroll_offset + self.options.scroll_padding_start {
786                resolved_align = ScrollAlignment::Start;
787            } else {
788                // Item is already visible.
789                return Some((scroll_offset, resolved_align));
790            }
791        }
792
793        // Compute the target offset.
794        let to_offset = if resolved_align == ScrollAlignment::End {
795            item.end + self.options.scroll_padding_end
796        } else {
797            item.start - self.options.scroll_padding_start
798        };
799
800        // Apply alignment.
801        let aligned_offset = self.get_offset_for_alignment(to_offset, resolved_align, item.size);
802
803        Some((aligned_offset, resolved_align))
804    }
805
806    /// Calculates the aligned scroll offset for a given target.
807    ///
808    /// # Parameters
809    ///
810    /// - `to_offset`: The raw target offset.
811    /// - `align`: The alignment strategy.
812    /// - `item_size`: The size of the target item (used for center alignment).
813    ///
814    /// # Returns
815    ///
816    /// - `f64`: The clamped and aligned scroll offset.
817    pub fn get_offset_for_alignment(
818        &self,
819        to_offset: f64,
820        align: ScrollAlignment,
821        item_size: f64,
822    ) -> f64 {
823        let size = self.container_size;
824
825        // Adjust offset based on alignment.
826        let adjusted = match align {
827            ScrollAlignment::Center => to_offset + (item_size - size) / 2.0,
828            ScrollAlignment::End => to_offset - size,
829            _ => to_offset,
830        };
831
832        // Clamp to valid bounds.
833        let max_offset = (self.total_size - self.container_size).max(0.0);
834        adjusted.clamp(0.0, max_offset)
835    }
836
837    /// Calculates the scroll offset to navigate to a specific item.
838    ///
839    /// This is a convenience wrapper that returns just the offset.
840    ///
841    /// # Parameters
842    ///
843    /// - `index`: The index of the target item.
844    /// - `alignment`: How to align the item within the viewport.
845    ///
846    /// # Returns
847    ///
848    /// - `Ok(f64)`: The scroll offset to navigate to.
849    /// - `Err(VirtualizerError)`: If the index is out of bounds.
850    ///
851    /// # Errors
852    ///
853    /// - Returns `IndexOutOfBounds` if index >= item_count.
854    pub fn scroll_to_index(
855        &self,
856        index: usize,
857        alignment: ScrollAlignment,
858    ) -> Result<f64, VirtualizerError> {
859        // Validate the index.
860        if index >= self.options.item_count {
861            return Err(VirtualizerError::IndexOutOfBounds {
862                requested: index,
863                total: self.options.item_count,
864            });
865        }
866
867        // Get the offset for the index.
868        let result = self.get_offset_for_index(index, alignment);
869
870        // Return the offset or fall back to current scroll offset.
871        Ok(result.map_or(self.scroll_offset, |(offset, _)| offset))
872    }
873
874    /// Prepares a scroll-to-index operation with full options.
875    ///
876    /// Returns the target offset, resolved alignment, and behavior for
877    /// the hook layer to apply to the DOM.
878    ///
879    /// # Parameters
880    ///
881    /// - `index`: The target item index.
882    /// - `options`: Scroll options including alignment and behavior.
883    /// - `now_ms`: Monotonic or wall time in milliseconds for reconciliation timeouts.
884    ///
885    /// # Returns
886    ///
887    /// - `Ok(ScrollState)`: The scroll state to reconcile.
888    /// - `Err(VirtualizerError)`: If the index is out of bounds.
889    ///
890    /// # Errors
891    ///
892    /// - Returns `IndexOutOfBounds` if index >= item_count.
893    pub fn prepare_scroll_to_index(
894        &mut self,
895        index: usize,
896        options: ScrollToOptions,
897        now_ms: f64,
898    ) -> Result<ScrollState, VirtualizerError> {
899        // Validate the index.
900        if index >= self.options.item_count {
901            return Err(VirtualizerError::IndexOutOfBounds {
902                requested: index,
903                total: self.options.item_count,
904            });
905        }
906
907        // Get the offset for the index.
908        let result = self.get_offset_for_index(index, options.align);
909        let (offset, _align) = result.unwrap_or((self.scroll_offset, options.align));
910
911        // Create a scroll state for reconciliation.
912        let state = ScrollState::new(Some(index), options.align, options.behavior, now_ms, offset);
913
914        // Store the scroll state.
915        self.scroll_state = Some(state.clone());
916        self.notify_change();
917
918        Ok(state)
919    }
920
921    /// Prepares a scroll-to-offset operation.
922    ///
923    /// Returns the target scroll state for the hook layer to apply.
924    ///
925    /// # Parameters
926    ///
927    /// - `to_offset`: The target scroll offset in pixels.
928    /// - `options`: Scroll options including alignment and behavior.
929    /// - `now_ms`: Timestamp in milliseconds for reconciliation timeouts.
930    ///
931    /// # Returns
932    ///
933    /// - `ScrollState`: The scroll state to reconcile.
934    pub fn prepare_scroll_to_offset(
935        &mut self,
936        to_offset: f64,
937        options: ScrollToOptions,
938        now_ms: f64,
939    ) -> ScrollState {
940        // Compute the aligned offset.
941        let offset = self.get_offset_for_alignment(to_offset, options.align, 0.0);
942
943        // Create a scroll state for reconciliation.
944        let state = ScrollState::new(None, options.align, options.behavior, now_ms, offset);
945
946        // Store the scroll state.
947        self.scroll_state = Some(state.clone());
948        self.notify_change();
949
950        state
951    }
952
953    /// Prepares a scroll-by operation (relative scroll).
954    ///
955    /// # Parameters
956    ///
957    /// - `delta`: The number of pixels to scroll by.
958    /// - `behavior`: The scroll animation behavior.
959    /// - `now_ms`: Timestamp in milliseconds for reconciliation timeouts.
960    ///
961    /// # Returns
962    ///
963    /// - `ScrollState`: The scroll state to reconcile.
964    pub fn prepare_scroll_by(
965        &mut self,
966        delta: f64,
967        behavior: ScrollBehavior,
968        now_ms: f64,
969    ) -> ScrollState {
970        // Compute the target offset.
971        let offset = self.scroll_offset + delta;
972
973        // Create a scroll state for reconciliation.
974        let state = ScrollState::new(None, ScrollAlignment::Start, behavior, now_ms, offset);
975
976        // Store the scroll state.
977        self.scroll_state = Some(state.clone());
978        self.notify_change();
979
980        state
981    }
982
983    /// Recomputes the programmatic scroll target after measurements change.
984    ///
985    /// Call this during smooth scroll reconciliation so the target offset
986    /// tracks dynamic item sizes.
987    pub fn refresh_programmatic_scroll_target(&mut self) {
988        // Read index and alignment without holding a mutable borrow across get_offset.
989        let (idx, align) = {
990            let Some(s) = self.scroll_state.as_ref() else {
991                return;
992            };
993            (s.index, s.align)
994        };
995
996        if let Some(i) = idx {
997            if let Some((off, _)) = self.get_offset_for_index(i, align) {
998                if let Some(s) = self.scroll_state.as_mut() {
999                    s.last_target_offset = off;
1000                }
1001            }
1002        }
1003    }
1004
1005    /// Advances programmatic scroll reconciliation for one animation frame.
1006    ///
1007    /// # Parameters
1008    ///
1009    /// - `current_scroll`: Observed scroll offset from the DOM.
1010    /// - `now_ms`: Current time in milliseconds.
1011    ///
1012    /// # Returns
1013    ///
1014    /// - `ScrollReconcileAction`: Whether to keep scheduling frames.
1015    pub fn scroll_reconciliation_tick(
1016        &mut self,
1017        current_scroll: f64,
1018        now_ms: f64,
1019    ) -> ScrollReconcileAction {
1020        // Stop immediately when no programmatic scroll is active.
1021        let Some(state) = self.scroll_state.as_mut() else {
1022            return ScrollReconcileAction::Done;
1023        };
1024
1025        let timeout_ms = self.options.scroll_reconciliation_timeout_ms as f64;
1026        if now_ms - state.started_at > timeout_ms {
1027            self.scroll_state = None;
1028            self.notify_change();
1029            return ScrollReconcileAction::Timeout;
1030        }
1031
1032        let target = state.last_target_offset;
1033        let diff = (current_scroll - target).abs();
1034        if diff < 1.0 {
1035            state.stable_frames = state.stable_frames.saturating_add(1);
1036        } else {
1037            state.stable_frames = 0;
1038        }
1039
1040        let needed = self.options.scroll_reconciliation_stable_frames;
1041        if state.stable_frames >= needed {
1042            self.scroll_state = None;
1043            self.notify_change();
1044            return ScrollReconcileAction::Done;
1045        }
1046
1047        ScrollReconcileAction::Continue
1048    }
1049
1050    /// Forces a re-measure of all items by clearing the size cache.
1051    pub fn measure(&mut self) {
1052        // Clear the size cache to force re-measurement.
1053        self.item_size_cache
1054            .clear(self.options.item_size_mode.base_size());
1055
1056        // Clear lane assignments for full re-layout.
1057        self.lane_assignments.clear();
1058
1059        // Rebuild everything.
1060        self.measurements_cache.clear();
1061        self.rebuild_measurements();
1062        self.recalculate_range();
1063        self.notify_change();
1064    }
1065
1066    /// Returns the virtual item at a given scroll offset.
1067    ///
1068    /// # Parameters
1069    ///
1070    /// - `offset`: The scroll offset to look up.
1071    ///
1072    /// # Returns
1073    ///
1074    /// - `Option<&VirtualItem>`: The item at the given offset.
1075    pub fn get_virtual_item_for_offset(&self, offset: f64) -> Option<&VirtualItem> {
1076        // Handle empty measurements.
1077        if self.measurements_cache.is_empty() {
1078            return None;
1079        }
1080
1081        // Binary search for the item closest to the offset.
1082        let last = self.measurements_cache.len() - 1;
1083        let mut low = 0usize;
1084        let mut high = last;
1085
1086        while low <= high {
1087            let middle = low + (high - low) / 2;
1088            let current_value = self.measurements_cache.get(middle).map_or(0.0, |i| i.start);
1089
1090            if current_value < offset {
1091                if middle == high {
1092                    break;
1093                }
1094                low = middle + 1;
1095            } else if current_value > offset {
1096                if middle == 0 {
1097                    break;
1098                }
1099                high = middle - 1;
1100            } else {
1101                return self.measurements_cache.get(middle);
1102            }
1103        }
1104
1105        // Return the closest item.
1106        let idx = if low > 0 { low - 1 } else { 0 };
1107        self.measurements_cache.get(idx)
1108    }
1109
1110    /// Returns the list of virtual items in the current visible range.
1111    ///
1112    /// Uses the custom range extractor if provided, otherwise returns
1113    /// items in the contiguous range with overscan applied.
1114    ///
1115    /// # Returns
1116    ///
1117    /// - `Vec<VirtualItem>`: Metadata for each item in the visible range.
1118    pub fn get_virtual_items(&self) -> Vec<VirtualItem> {
1119        // Handle disabled state.
1120        if !self.options.enabled {
1121            return Vec::new();
1122        }
1123
1124        let key = self.virtual_items_cache_key();
1125
1126        // Return a clone from the memo cache when the fingerprint matches.
1127        {
1128            let cache = self.virtual_items_cache.borrow();
1129            if let Some((cached_key, cached_vec)) = cache.as_ref() {
1130                if *cached_key == key {
1131                    return cached_vec.clone();
1132                }
1133            }
1134        }
1135
1136        // Get the rendered indices.
1137        let indexes = self.get_virtual_indexes();
1138
1139        // Map indices to measurements.
1140        let mut items = Vec::with_capacity(indexes.len());
1141        for i in indexes {
1142            if let Some(measurement) = self.measurements_cache.get(i) {
1143                items.push(measurement.clone());
1144            }
1145        }
1146
1147        *self.virtual_items_cache.borrow_mut() = Some((key, items.clone()));
1148
1149        items
1150    }
1151
1152    /// Returns the indices that should be rendered.
1153    ///
1154    /// # Returns
1155    ///
1156    /// - `Vec<usize>`: The item indices to render.
1157    fn get_virtual_indexes(&self) -> Vec<usize> {
1158        // Build the visible range descriptor.
1159        let visible_range = VisibleRange {
1160            start_index: self.range_start,
1161            end_index: self.range_end,
1162            overscan: self.options.overscan,
1163            count: self.options.item_count,
1164        };
1165
1166        // Use custom range extractor if provided.
1167        if let Some(ref extractor) = self.options.range_extractor {
1168            return extractor(visible_range);
1169        }
1170
1171        // Default range extraction with overscan.
1172        Self::default_range_extractor(visible_range)
1173    }
1174
1175    /// Default range extractor that applies overscan to the visible range.
1176    ///
1177    /// # Parameters
1178    ///
1179    /// - `range`: The visible range descriptor.
1180    ///
1181    /// # Returns
1182    ///
1183    /// - `Vec<usize>`: The contiguous set of indices to render.
1184    fn default_range_extractor(range: VisibleRange) -> Vec<usize> {
1185        // Handle empty range.
1186        if range.count == 0 {
1187            return Vec::new();
1188        }
1189
1190        // Apply overscan to start and end.
1191        let start = range.start_index.saturating_sub(range.overscan);
1192        let end = (range.end_index + range.overscan).min(range.count.saturating_sub(1));
1193
1194        // Build the index list.
1195        (start..=end).collect()
1196    }
1197
1198    /// Returns the total scrollable size of the virtualized region.
1199    ///
1200    /// # Returns
1201    ///
1202    /// - `f64`: Total size in pixels including padding.
1203    pub fn total_size(&self) -> f64 {
1204        // Return the precomputed total size.
1205        self.total_size
1206    }
1207
1208    /// Returns the current scroll offset.
1209    ///
1210    /// # Returns
1211    ///
1212    /// - `f64`: Current scroll position in pixels.
1213    pub fn scroll_offset(&self) -> f64 {
1214        // Return the current scroll offset.
1215        self.scroll_offset
1216    }
1217
1218    /// Returns the current container viewport size.
1219    ///
1220    /// # Returns
1221    ///
1222    /// - `f64`: Current container size in pixels.
1223    pub fn container_size(&self) -> f64 {
1224        // Return the current container size.
1225        self.container_size
1226    }
1227
1228    /// Returns the start index of the visible range (inclusive).
1229    ///
1230    /// # Returns
1231    ///
1232    /// - `usize`: The first index in the visible range.
1233    pub fn range_start(&self) -> usize {
1234        // Return the range start.
1235        self.range_start
1236    }
1237
1238    /// Returns the end index of the visible range (inclusive).
1239    ///
1240    /// # Returns
1241    ///
1242    /// - `usize`: The last index in the visible range.
1243    pub fn range_end(&self) -> usize {
1244        // Return the range end.
1245        self.range_end
1246    }
1247
1248    /// Returns the current virtualizer options.
1249    ///
1250    /// # Returns
1251    ///
1252    /// - `&VirtualizerOptions`: Reference to the current options.
1253    pub fn options(&self) -> &VirtualizerOptions {
1254        // Return a reference to the options.
1255        &self.options
1256    }
1257
1258    /// Returns the item count.
1259    ///
1260    /// # Returns
1261    ///
1262    /// - `usize`: The total number of items.
1263    pub fn item_count(&self) -> usize {
1264        // Return the item count from options.
1265        self.options.item_count
1266    }
1267
1268    /// Returns the measurement cache.
1269    ///
1270    /// # Returns
1271    ///
1272    /// - `&MeasurementCache`: Reference to the measurement cache.
1273    pub fn measurement_cache(&self) -> &MeasurementCache {
1274        // Return a reference to the cache.
1275        &self.item_size_cache
1276    }
1277
1278    /// Returns the full measurements array.
1279    ///
1280    /// # Returns
1281    ///
1282    /// - `&[VirtualItem]`: The precomputed measurements for all items.
1283    pub fn measurements(&self) -> &[VirtualItem] {
1284        // Return the measurements cache slice.
1285        &self.measurements_cache
1286    }
1287
1288    /// Returns whether the virtualizer requires item measurement.
1289    ///
1290    /// # Returns
1291    ///
1292    /// - `bool`: True if the item size mode requires runtime measurement.
1293    pub fn requires_measurement(&self) -> bool {
1294        // Delegate to the item size mode.
1295        self.options.item_size_mode.requires_measurement()
1296    }
1297
1298    /// Returns whether the user is currently scrolling.
1299    ///
1300    /// # Returns
1301    ///
1302    /// - `bool`: True if a scroll event is in progress.
1303    pub fn is_scrolling(&self) -> bool {
1304        // Return the current scrolling state.
1305        self.is_scrolling
1306    }
1307
1308    /// Returns whether the last scroll was forward (down/right).
1309    ///
1310    /// # Returns
1311    ///
1312    /// - `Option<bool>`: True if forward, false if backward, None if no scroll.
1313    pub fn is_scroll_forward(&self) -> Option<bool> {
1314        // Return the scroll direction.
1315        self.scroll_forward
1316    }
1317
1318    /// Returns the accumulated scroll adjustments.
1319    ///
1320    /// # Returns
1321    ///
1322    /// - `f64`: The accumulated adjustments in pixels.
1323    pub fn scroll_adjustments(&self) -> f64 {
1324        // Return the current adjustments.
1325        self.scroll_adjustments
1326    }
1327
1328    /// Returns the active scroll state, if any.
1329    ///
1330    /// # Returns
1331    ///
1332    /// - `Option<&ScrollState>`: The active scroll operation state.
1333    pub fn scroll_state(&self) -> Option<&ScrollState> {
1334        // Return a reference to the scroll state.
1335        self.scroll_state.as_ref()
1336    }
1337
1338    /// Clears the active scroll state.
1339    pub fn clear_scroll_state(&mut self) {
1340        // Remove the active scroll state.
1341        self.scroll_state = None;
1342        self.notify_change();
1343    }
1344
1345    /// Returns whether the virtualizer is enabled.
1346    ///
1347    /// # Returns
1348    ///
1349    /// - `bool`: True if the virtualizer is enabled.
1350    pub fn is_enabled(&self) -> bool {
1351        // Return the enabled state.
1352        self.options.enabled
1353    }
1354
1355    /// Sets the is_scrolling state.
1356    ///
1357    /// # Parameters
1358    ///
1359    /// - `is_scrolling`: The new scrolling state.
1360    pub fn set_is_scrolling(&mut self, is_scrolling: bool) {
1361        // Update the scrolling state.
1362        self.is_scrolling = is_scrolling;
1363
1364        // Clear direction when scrolling stops.
1365        if !is_scrolling {
1366            self.scroll_forward = None;
1367        }
1368
1369        self.notify_change();
1370    }
1371
1372    /// Returns the size of a specific item.
1373    ///
1374    /// # Parameters
1375    ///
1376    /// - `index`: The item index.
1377    ///
1378    /// # Returns
1379    ///
1380    /// - `Option<f64>`: The item size if the index is valid.
1381    pub fn item_size(&self, index: usize) -> Option<f64> {
1382        // Look up from measurements.
1383        self.measurements_cache.get(index).map(|item| item.size)
1384    }
1385
1386    /// Returns the offset of a specific item.
1387    ///
1388    /// # Parameters
1389    ///
1390    /// - `index`: The item index.
1391    ///
1392    /// # Returns
1393    ///
1394    /// - `Option<f64>`: The item offset if the index is valid.
1395    pub fn item_offset(&self, index: usize) -> Option<f64> {
1396        // Look up from measurements.
1397        self.measurements_cache.get(index).map(|item| item.start)
1398    }
1399
1400    /// Recalculates the visible range from current scroll state.
1401    fn recalculate_range(&mut self) {
1402        // Delegate to the range calculator.
1403        let result = RangeCalculator::calculate_range(
1404            &self.measurements_cache,
1405            self.container_size,
1406            self.scroll_offset,
1407            self.options.lanes,
1408        );
1409
1410        // Update the range if a valid result was returned.
1411        if let Some((start, end)) = result {
1412            self.range_start = start;
1413            self.range_end = end;
1414        } else {
1415            self.range_start = 0;
1416            self.range_end = 0;
1417        }
1418    }
1419}