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}