Skip to main content

fret_ui/virtual_list/
mod.rs

1use fret_core::Px;
2use std::sync::Arc;
3
4use crate::element::VirtualListMeasureMode;
5use crate::scroll::ScrollStrategy;
6
7#[cfg(test)]
8use std::cell::Cell;
9
10const VIRTUALIZER_PX_SCALE: f32 = 64.0;
11
12fn px_to_units_u32(px: Px) -> u32 {
13    let scaled = (px.0.max(0.0) * VIRTUALIZER_PX_SCALE).round();
14    scaled.clamp(0.0, u32::MAX as f32) as u32
15}
16
17fn px_to_units_u64(px: Px) -> u64 {
18    let scaled = (px.0.max(0.0) * VIRTUALIZER_PX_SCALE).round();
19    scaled.clamp(0.0, u64::MAX as f32) as u64
20}
21
22fn units_u32_to_px(units: u32) -> Px {
23    Px(units as f32 / VIRTUALIZER_PX_SCALE)
24}
25
26fn units_u64_to_px(units: u64) -> Px {
27    Px(units as f32 / VIRTUALIZER_PX_SCALE)
28}
29
30#[derive(Debug, Clone, Copy, PartialEq)]
31pub struct VirtualItem {
32    pub key: crate::ItemKey,
33    pub index: usize,
34    pub start: Px,
35    pub end: Px,
36    pub size: Px,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub struct VirtualRange {
41    pub start_index: usize,
42    pub end_index: usize,
43    pub overscan: usize,
44    pub count: usize,
45}
46
47pub fn default_range_extractor(range: VirtualRange) -> Vec<usize> {
48    if range.count == 0 {
49        return Vec::new();
50    }
51    let start = range.start_index.saturating_sub(range.overscan);
52    let end = (range.end_index + range.overscan).min(range.count.saturating_sub(1));
53
54    (start..=end).collect()
55}
56
57pub(crate) fn shift_virtual_range_minimally(
58    rendered: VirtualRange,
59    visible: VirtualRange,
60) -> VirtualRange {
61    let overscan = rendered.overscan;
62    let count = rendered.count;
63    if count == 0 {
64        return rendered;
65    }
66
67    let inner_len = rendered.end_index.saturating_sub(rendered.start_index);
68    let rendered_outer_start = rendered.start_index.saturating_sub(overscan);
69    let rendered_outer_end = (rendered.end_index + overscan).min(count.saturating_sub(1));
70
71    let mut start = rendered.start_index;
72    let mut end = rendered.end_index;
73
74    if visible.start_index < rendered_outer_start {
75        start = visible.start_index.saturating_add(overscan);
76        end = start.saturating_add(inner_len);
77    } else if visible.end_index > rendered_outer_end {
78        end = visible.end_index.saturating_sub(overscan);
79        start = end.saturating_sub(inner_len);
80    }
81
82    if end >= count {
83        end = count.saturating_sub(1);
84        start = end.saturating_sub(inner_len);
85    }
86    if start >= count {
87        start = count.saturating_sub(1);
88    }
89    if start > end {
90        end = start;
91    }
92
93    VirtualRange {
94        start_index: start,
95        end_index: end,
96        overscan,
97        count,
98    }
99}
100
101pub(crate) fn prefetch_virtual_range_step(
102    rendered: VirtualRange,
103    visible: VirtualRange,
104    prefetch_margin: usize,
105    prefetch_step: usize,
106    prefer_forward: Option<bool>,
107) -> Option<VirtualRange> {
108    let overscan = rendered.overscan;
109    let count = rendered.count;
110    if count == 0 || overscan == 0 || prefetch_step == 0 {
111        return None;
112    }
113
114    let inner_len = rendered.end_index.saturating_sub(rendered.start_index);
115    let rendered_outer_start = rendered.start_index.saturating_sub(overscan);
116    let rendered_outer_end = (rendered.end_index + overscan).min(count.saturating_sub(1));
117
118    // Prefetch is only valid if the visible range is still covered by the currently-rendered
119    // prefetch window. Escapes are handled by `shift_virtual_range_minimally`.
120    if visible.start_index < rendered_outer_start || visible.end_index > rendered_outer_end {
121        return None;
122    }
123
124    let near_start = visible.start_index <= rendered_outer_start.saturating_add(prefetch_margin);
125    let near_end = visible.end_index >= rendered_outer_end.saturating_sub(prefetch_margin);
126    if !near_start && !near_end {
127        return None;
128    }
129
130    let want_forward = if near_end && !near_start {
131        true
132    } else if near_start && !near_end {
133        false
134    } else {
135        // Both sides are "near" (small windows, small overscan, or being close to the list start/end).
136        // Prefer the caller's scroll direction hint to avoid prefetch oscillation during slow scroll.
137        // If we don't have a direction hint, skip prefetch (the caller can fall back to escape logic).
138        prefer_forward?
139    };
140
141    let mut start = rendered.start_index;
142
143    let delta = if want_forward {
144        // Ensure the forward shift does not exclude the visible start from the new expanded window.
145        let max_delta = visible
146            .start_index
147            .saturating_add(overscan)
148            .saturating_sub(rendered.start_index);
149        prefetch_step.min(max_delta)
150    } else {
151        // Ensure the backward shift does not exclude the visible end from the new expanded window.
152        let max_delta = rendered
153            .end_index
154            .saturating_add(overscan)
155            .saturating_sub(visible.end_index);
156        prefetch_step.min(max_delta)
157    };
158
159    if delta == 0 {
160        return None;
161    }
162
163    if want_forward {
164        start = start.saturating_add(delta);
165    } else {
166        start = start.saturating_sub(delta);
167    }
168
169    // Keep the inner length stable across prefetch shifts (especially when saturating at 0).
170    let mut end = start.saturating_add(inner_len);
171
172    if end >= count {
173        end = count.saturating_sub(1);
174        start = end.saturating_sub(inner_len);
175    }
176    if start >= count {
177        start = count.saturating_sub(1);
178    }
179    if start > end {
180        end = start;
181    }
182
183    let next = VirtualRange {
184        start_index: start,
185        end_index: end,
186        overscan,
187        count,
188    };
189    (next != rendered).then_some(next)
190}
191
192pub(crate) fn visible_item_index_span(items: &[VirtualItem]) -> Option<(usize, usize)> {
193    let first = items.first()?.index;
194    let mut prev = first;
195    for item in items.iter().skip(1) {
196        if item.index <= prev {
197            return None;
198        }
199        prev = item.index;
200    }
201    Some((first, prev))
202}
203
204pub(crate) fn expanded_range_index_span(range: VirtualRange) -> Option<(usize, usize)> {
205    if range.count == 0 {
206        return None;
207    }
208    let start = range.start_index.saturating_sub(range.overscan);
209    let end = (range.end_index + range.overscan).min(range.count.saturating_sub(1));
210    Some((start, end))
211}
212
213pub(crate) fn virtual_list_needs_visible_range_refresh(
214    mounted_items: &[VirtualItem],
215    desired_range: VirtualRange,
216) -> bool {
217    let Some((desired_start, desired_end)) = expanded_range_index_span(desired_range) else {
218        return false;
219    };
220    if mounted_items.is_empty() {
221        return true;
222    }
223    let Some((mounted_start, mounted_end)) = visible_item_index_span(mounted_items) else {
224        return true;
225    };
226    desired_start < mounted_start || desired_end > mounted_end
227}
228
229#[derive(Debug, Clone)]
230pub struct VirtualListMetrics {
231    estimate: Px,
232    gap: Px,
233    scroll_margin: Px,
234    mode: VirtualListMeasureMode,
235    inner: virtualizer::Virtualizer<crate::ItemKey>,
236    keys_signature: (u64, usize),
237    measured_cross_extent_units: u32,
238    fixed: FixedMetrics,
239}
240
241#[derive(Debug, Clone, Copy)]
242struct FixedMetrics {
243    count: usize,
244    estimate_units: u32,
245    gap_units: u32,
246    padding_start_units: u32,
247}
248
249impl Default for VirtualListMetrics {
250    fn default() -> Self {
251        let options = virtualizer::VirtualizerOptions::new(0, |_| 0);
252        Self {
253            estimate: Px(0.0),
254            gap: Px(0.0),
255            scroll_margin: Px(0.0),
256            mode: VirtualListMeasureMode::Measured,
257            inner: virtualizer::Virtualizer::new(options),
258            keys_signature: (0, 0),
259            measured_cross_extent_units: 0,
260            fixed: FixedMetrics {
261                count: 0,
262                estimate_units: 0,
263                gap_units: 0,
264                padding_start_units: 0,
265            },
266        }
267    }
268}
269
270impl VirtualListMetrics {
271    pub fn ensure_with_mode(
272        &mut self,
273        mode: VirtualListMeasureMode,
274        len: usize,
275        estimate: Px,
276        gap: Px,
277        scroll_margin: Px,
278    ) {
279        match mode {
280            VirtualListMeasureMode::Measured => {
281                self.ensure_measured(len, estimate, gap, scroll_margin)
282            }
283            VirtualListMeasureMode::Fixed => self.ensure_fixed(len, estimate, gap, scroll_margin),
284            VirtualListMeasureMode::Known => self.ensure_known(len, estimate, gap, scroll_margin),
285        }
286    }
287
288    pub fn ensure(&mut self, len: usize, estimate: Px, gap: Px, scroll_margin: Px) {
289        self.ensure_measured(len, estimate, gap, scroll_margin);
290    }
291
292    fn ensure_fixed(&mut self, len: usize, estimate: Px, gap: Px, scroll_margin: Px) {
293        let estimate = Px(estimate.0.max(0.0));
294        let gap = Px(gap.0.max(0.0));
295        let scroll_margin = Px(scroll_margin.0.max(0.0));
296
297        if self.mode == VirtualListMeasureMode::Fixed
298            && self.fixed.count == len
299            && self.estimate == estimate
300            && self.gap == gap
301            && self.scroll_margin == scroll_margin
302        {
303            return;
304        }
305
306        self.mode = VirtualListMeasureMode::Fixed;
307        self.estimate = estimate;
308        self.gap = gap;
309        self.scroll_margin = scroll_margin;
310
311        self.fixed = FixedMetrics {
312            count: len,
313            estimate_units: px_to_units_u32(estimate),
314            gap_units: px_to_units_u32(gap),
315            padding_start_units: px_to_units_u32(scroll_margin),
316        };
317    }
318
319    fn ensure_known(&mut self, len: usize, estimate: Px, gap: Px, scroll_margin: Px) {
320        let estimate = Px(estimate.0.max(0.0));
321        let gap = Px(gap.0.max(0.0));
322        let scroll_margin = Px(scroll_margin.0.max(0.0));
323        if self.mode == VirtualListMeasureMode::Known
324            && self.inner.options().count == len
325            && self.estimate == estimate
326            && self.gap == gap
327            && self.scroll_margin == scroll_margin
328        {
329            return;
330        }
331
332        self.mode = VirtualListMeasureMode::Known;
333        self.estimate = estimate;
334        self.gap = gap;
335        self.scroll_margin = scroll_margin;
336
337        let estimate_units = px_to_units_u32(estimate);
338        let gap_units = px_to_units_u32(gap);
339        let padding_start = px_to_units_u32(scroll_margin);
340
341        let mut options = self.inner.options().clone();
342        options.count = len;
343        options.gap = gap_units;
344        options.padding_start = padding_start;
345        options.padding_end = 0;
346        options.scroll_margin = 0;
347        options.estimate_size = Arc::new(move |_| estimate_units);
348        self.inner.set_options(options);
349    }
350
351    fn ensure_measured(&mut self, len: usize, estimate: Px, gap: Px, scroll_margin: Px) {
352        let estimate = Px(estimate.0.max(0.0));
353        let gap = Px(gap.0.max(0.0));
354        let scroll_margin = Px(scroll_margin.0.max(0.0));
355        if self.mode == VirtualListMeasureMode::Measured
356            && self.inner.options().count == len
357            && self.estimate == estimate
358            && self.gap == gap
359            && self.scroll_margin == scroll_margin
360        {
361            return;
362        }
363
364        self.mode = VirtualListMeasureMode::Measured;
365        self.estimate = estimate;
366        self.gap = gap;
367        self.scroll_margin = scroll_margin;
368
369        let estimate_units = px_to_units_u32(estimate);
370        let gap_units = px_to_units_u32(gap);
371        let padding_start = px_to_units_u32(scroll_margin);
372
373        let mut options = self.inner.options().clone();
374        options.count = len;
375        options.gap = gap_units;
376        options.padding_start = padding_start;
377        options.padding_end = 0;
378        options.scroll_margin = 0;
379        options.estimate_size = Arc::new(move |_| estimate_units);
380        self.inner.set_options(options);
381    }
382
383    pub fn sync_keys(&mut self, keys: &[crate::ItemKey], items_revision: u64) {
384        let signature = (items_revision, keys.len());
385        if self.keys_signature == signature {
386            return;
387        }
388
389        if self.mode == VirtualListMeasureMode::Fixed {
390            self.keys_signature = signature;
391            return;
392        }
393
394        let keys = Arc::new(keys.to_vec());
395        let mut options = self.inner.options().clone();
396        options.get_item_key = Arc::new({
397            let keys = Arc::clone(&keys);
398            move |i| keys.get(i).copied().unwrap_or(i as crate::ItemKey)
399        });
400        self.inner.set_options(options);
401        self.keys_signature = signature;
402    }
403
404    pub fn total_height(&self) -> Px {
405        match self.mode {
406            VirtualListMeasureMode::Measured => units_u64_to_px(self.inner.total_size()),
407            VirtualListMeasureMode::Known => units_u64_to_px(self.inner.total_size()),
408            VirtualListMeasureMode::Fixed => {
409                let count = self.fixed.count as u64;
410                if count == 0 {
411                    return Px(0.0);
412                }
413
414                let estimate = self.fixed.estimate_units as u64;
415                let gap = self.fixed.gap_units as u64;
416                let padding_start = self.fixed.padding_start_units as u64;
417
418                let gaps = count.saturating_sub(1);
419                let total_units = padding_start
420                    .saturating_add(count.saturating_mul(estimate))
421                    .saturating_add(gaps.saturating_mul(gap));
422                units_u64_to_px(total_units)
423            }
424        }
425    }
426
427    pub fn virtual_item(&self, index: usize, key: crate::ItemKey) -> VirtualItem {
428        let start = self.offset_for_index(index);
429        let size = self.height_at(index);
430        let end = Px((start.0 + size.0).max(0.0));
431        VirtualItem {
432            key,
433            index,
434            start,
435            end,
436            size,
437        }
438    }
439
440    pub fn estimate(&self) -> Px {
441        self.estimate
442    }
443
444    pub fn gap(&self) -> Px {
445        self.gap
446    }
447
448    pub fn scroll_margin(&self) -> Px {
449        self.scroll_margin
450    }
451
452    pub fn is_measured(&self, index: usize) -> bool {
453        match self.mode {
454            VirtualListMeasureMode::Measured | VirtualListMeasureMode::Known => {
455                if index >= self.inner.options().count {
456                    return false;
457                }
458                self.inner.is_measured(index)
459            }
460            VirtualListMeasureMode::Fixed => index < self.fixed.count,
461        }
462    }
463
464    pub fn reset_measured_cache_if_cross_extent_changed(&mut self, cross_extent: Px) -> bool {
465        if self.mode != VirtualListMeasureMode::Measured {
466            return false;
467        }
468
469        let units = px_to_units_u32(Px(cross_extent.0.max(0.0)));
470        if self.measured_cross_extent_units == units {
471            return false;
472        }
473
474        self.measured_cross_extent_units = units;
475        let options = self.inner.options().clone();
476        self.inner = virtualizer::Virtualizer::new(options);
477        true
478    }
479
480    pub fn height_at(&self, index: usize) -> Px {
481        match self.mode {
482            VirtualListMeasureMode::Measured | VirtualListMeasureMode::Known => self
483                .inner
484                .item_size(index)
485                .map(units_u32_to_px)
486                .unwrap_or(Px(0.0)),
487            VirtualListMeasureMode::Fixed => {
488                if index >= self.fixed.count {
489                    return Px(0.0);
490                }
491                units_u32_to_px(self.fixed.estimate_units)
492            }
493        }
494    }
495
496    pub fn offset_for_index(&self, index: usize) -> Px {
497        match self.mode {
498            VirtualListMeasureMode::Measured | VirtualListMeasureMode::Known => {
499                if index >= self.inner.options().count {
500                    return self.total_height();
501                }
502                self.inner
503                    .item_start(index)
504                    .map(units_u64_to_px)
505                    .unwrap_or(Px(0.0))
506            }
507            VirtualListMeasureMode::Fixed => {
508                if index >= self.fixed.count {
509                    return self.total_height();
510                }
511
512                let stride =
513                    (self.fixed.estimate_units as u64).saturating_add(self.fixed.gap_units as u64);
514                let start_units = (self.fixed.padding_start_units as u64)
515                    .saturating_add((index as u64).saturating_mul(stride));
516                units_u64_to_px(start_units)
517            }
518        }
519    }
520
521    pub fn end_for_index(&self, index: usize) -> Px {
522        match self.mode {
523            VirtualListMeasureMode::Measured | VirtualListMeasureMode::Known => {
524                if index >= self.inner.options().count {
525                    return self.total_height();
526                }
527                self.inner
528                    .item_end(index)
529                    .map(units_u64_to_px)
530                    .unwrap_or(Px(0.0))
531            }
532            VirtualListMeasureMode::Fixed => {
533                if index >= self.fixed.count {
534                    return self.total_height();
535                }
536                let start = px_to_units_u64(self.offset_for_index(index));
537                let end = start.saturating_add(self.fixed.estimate_units as u64);
538                units_u64_to_px(end)
539            }
540        }
541    }
542
543    pub fn index_for_offset(&self, offset: Px) -> usize {
544        match self.mode {
545            VirtualListMeasureMode::Measured | VirtualListMeasureMode::Known => {
546                if self.inner.options().count == 0 {
547                    return 0;
548                }
549                if offset.0 >= self.total_height().0 {
550                    return self.inner.options().count;
551                }
552                self.inner
553                    .index_at_offset(px_to_units_u64(offset))
554                    .unwrap_or(0)
555            }
556            VirtualListMeasureMode::Fixed => {
557                let count = self.fixed.count;
558                if count == 0 {
559                    return 0;
560                }
561                if offset.0 >= self.total_height().0 {
562                    return count;
563                }
564
565                let offset_units = px_to_units_u64(offset);
566                let padding_start = self.fixed.padding_start_units as u64;
567                if offset_units <= padding_start {
568                    return 0;
569                }
570                let stride =
571                    (self.fixed.estimate_units as u64).saturating_add(self.fixed.gap_units as u64);
572                if stride == 0 {
573                    return 0;
574                }
575
576                let adjusted = offset_units.saturating_sub(padding_start);
577                let idx = adjusted / stride;
578                (idx as usize).min(count)
579            }
580        }
581    }
582
583    pub fn end_index_for_offset(&self, offset: Px) -> usize {
584        match self.mode {
585            VirtualListMeasureMode::Measured | VirtualListMeasureMode::Known => {
586                if self.inner.options().count == 0 {
587                    return 0;
588                }
589                let idx = self.index_for_offset(offset);
590                if idx >= self.inner.options().count {
591                    return self.inner.options().count;
592                }
593                let start = self.offset_for_index(idx).0;
594                if start < offset.0 {
595                    idx.saturating_add(1).min(self.inner.options().count)
596                } else {
597                    idx
598                }
599            }
600            VirtualListMeasureMode::Fixed => {
601                let count = self.fixed.count;
602                if count == 0 {
603                    return 0;
604                }
605                let idx = self.index_for_offset(offset);
606                if idx >= count {
607                    return count;
608                }
609                let start = self.offset_for_index(idx).0;
610                if start < offset.0 {
611                    idx.saturating_add(1).min(count)
612                } else {
613                    idx
614                }
615            }
616        }
617    }
618
619    pub fn set_measured_height(&mut self, index: usize, height: Px) -> bool {
620        if self.mode == VirtualListMeasureMode::Fixed {
621            return false;
622        }
623
624        let Some(old_units) = self.inner.item_size(index) else {
625            return false;
626        };
627
628        let height = Px(height.0.max(0.0));
629        let height_units = px_to_units_u32(height);
630        let changed = old_units != height_units;
631        if !changed && self.inner.is_measured(index) {
632            return false;
633        }
634
635        self.inner.measure_unadjusted(index, height_units);
636        true
637    }
638
639    pub fn clamp_offset(&self, mut offset_y: Px, viewport_h: Px) -> Px {
640        let viewport_h = Px(viewport_h.0.max(0.0));
641        let total = px_to_units_u64(self.total_height());
642        let max_offset_units = total.saturating_sub(px_to_units_u64(viewport_h));
643        let max_offset = units_u64_to_px(max_offset_units);
644        offset_y = Px(offset_y.0.max(0.0));
645        Px(offset_y.0.min(max_offset.0))
646    }
647
648    /// Computes the visible item range for a vertical viewport.
649    ///
650    /// `offset_y` is the current scroll offset, clamped by the caller as needed.
651    ///
652    /// Returns a [`VirtualRange`] with **inclusive** indices (`start_index..=end_index`).
653    pub fn visible_range(
654        &self,
655        offset_y: Px,
656        viewport_h: Px,
657        overscan: usize,
658    ) -> Option<VirtualRange> {
659        let viewport_h = Px(viewport_h.0.max(0.0));
660        let count = match self.mode {
661            VirtualListMeasureMode::Measured | VirtualListMeasureMode::Known => {
662                self.inner.options().count
663            }
664            VirtualListMeasureMode::Fixed => self.fixed.count,
665        };
666        if viewport_h.0 <= 0.0 || count == 0 {
667            return None;
668        }
669
670        let start = self.index_for_offset(offset_y);
671        if start >= count {
672            return None;
673        }
674        let end_exclusive = self.end_index_for_offset(Px(offset_y.0 + viewport_h.0));
675        let end = end_exclusive.saturating_sub(1).min(count.saturating_sub(1));
676
677        Some(VirtualRange {
678            start_index: start,
679            end_index: end,
680            overscan,
681            count,
682        })
683    }
684
685    pub fn scroll_offset_for_item(
686        &self,
687        index: usize,
688        viewport_h: Px,
689        current_offset_y: Px,
690        strategy: ScrollStrategy,
691    ) -> Px {
692        let viewport_h = Px(viewport_h.0.max(0.0));
693        if viewport_h.0 <= 0.0 {
694            return current_offset_y;
695        }
696
697        let count = match self.mode {
698            VirtualListMeasureMode::Measured | VirtualListMeasureMode::Known => {
699                self.inner.options().count
700            }
701            VirtualListMeasureMode::Fixed => self.fixed.count,
702        };
703        if count == 0 {
704            return current_offset_y;
705        }
706        let index = index.min(count.saturating_sub(1));
707
708        let item_top = self.offset_for_index(index);
709        let item_bottom = self.end_for_index(index);
710
711        let view_top = current_offset_y;
712        let view_bottom = Px(current_offset_y.0 + viewport_h.0);
713
714        match strategy {
715            ScrollStrategy::Start => item_top,
716            ScrollStrategy::End => Px(item_bottom.0 - viewport_h.0),
717            ScrollStrategy::Center => {
718                let item_center = 0.5 * (item_top.0 + item_bottom.0);
719                Px(item_center - 0.5 * viewport_h.0)
720            }
721            ScrollStrategy::Nearest => {
722                if item_top.0 < view_top.0 {
723                    item_top
724                } else if item_bottom.0 > view_bottom.0 {
725                    Px(item_bottom.0 - viewport_h.0)
726                } else {
727                    current_offset_y
728                }
729            }
730        }
731    }
732
733    pub fn rebuild_from_heights(
734        &mut self,
735        heights: Vec<Px>,
736        measured: Vec<bool>,
737        estimate: Px,
738        gap: Px,
739        scroll_margin: Px,
740    ) {
741        let len = heights.len();
742        self.ensure_measured(len, estimate, gap, scroll_margin);
743
744        let mut entries = Vec::new();
745        for (index, height) in heights.into_iter().enumerate() {
746            let is_measured = measured.get(index).copied().unwrap_or(false);
747            if !is_measured {
748                continue;
749            }
750            entries.push((self.inner.key_for(index), px_to_units_u32(height)));
751        }
752        self.inner.import_measurement_cache(entries);
753    }
754
755    pub fn rebuild_from_known_heights(
756        &mut self,
757        heights: Vec<Px>,
758        estimate: Px,
759        gap: Px,
760        scroll_margin: Px,
761    ) {
762        let len = heights.len();
763        self.ensure_known(len, estimate, gap, scroll_margin);
764
765        let mut entries = Vec::with_capacity(len);
766        for (index, height) in heights.into_iter().enumerate() {
767            entries.push((self.inner.key_for(index), px_to_units_u32(height)));
768        }
769        self.inner.import_measurement_cache(entries);
770    }
771}
772
773#[cfg(test)]
774thread_local! {
775    static VIRTUAL_LIST_ITEM_MEASURE_CALLS: Cell<usize> = const { Cell::new(0) };
776}
777
778#[cfg(test)]
779pub(crate) fn debug_record_virtual_list_item_measure() {
780    VIRTUAL_LIST_ITEM_MEASURE_CALLS.with(|calls| {
781        calls.set(calls.get().saturating_add(1));
782    });
783}
784
785#[cfg(test)]
786pub(crate) fn debug_take_virtual_list_item_measures() -> usize {
787    VIRTUAL_LIST_ITEM_MEASURE_CALLS.with(|calls| {
788        let value = calls.get();
789        calls.set(0);
790        value
791    })
792}
793
794#[cfg(test)]
795mod tests {
796    use super::*;
797
798    fn dummy_items(indices: &[usize]) -> Vec<VirtualItem> {
799        indices
800            .iter()
801            .copied()
802            .map(|index| VirtualItem {
803                key: index as crate::ItemKey,
804                index,
805                start: Px(0.0),
806                end: Px(0.0),
807                size: Px(0.0),
808            })
809            .collect()
810    }
811
812    #[test]
813    fn virtual_list_needs_visible_range_refresh_when_span_exceeded() {
814        let mounted = dummy_items(&[0, 1, 2, 3, 4]);
815        let desired = VirtualRange {
816            start_index: 1,
817            end_index: 3,
818            overscan: 0,
819            count: 100,
820        };
821        assert!(!virtual_list_needs_visible_range_refresh(&mounted, desired));
822
823        let desired = VirtualRange {
824            start_index: 5,
825            end_index: 6,
826            overscan: 0,
827            count: 100,
828        };
829        assert!(virtual_list_needs_visible_range_refresh(&mounted, desired));
830
831        let desired = VirtualRange {
832            start_index: 4,
833            end_index: 4,
834            overscan: 2,
835            count: 100,
836        };
837        // Expanded is 2..=6, which exceeds the mounted span 0..=4.
838        assert!(virtual_list_needs_visible_range_refresh(&mounted, desired));
839    }
840
841    #[test]
842    fn visible_item_index_span_requires_strictly_increasing_indices() {
843        assert_eq!(
844            visible_item_index_span(&dummy_items(&[0, 1, 2])),
845            Some((0, 2))
846        );
847        assert_eq!(visible_item_index_span(&dummy_items(&[2])), Some((2, 2)));
848        assert_eq!(visible_item_index_span(&dummy_items(&[1, 1])), None);
849        assert_eq!(visible_item_index_span(&dummy_items(&[2, 1])), None);
850    }
851
852    #[test]
853    fn fenwick_sums_match_uniform_heights() {
854        let mut metrics = VirtualListMetrics::default();
855        metrics.ensure(100, Px(10.0), Px(0.0), Px(0.0));
856
857        assert!((metrics.total_height().0 - 1000.0).abs() < 0.01);
858        assert!((metrics.offset_for_index(0).0 - 0.0).abs() < 0.01);
859        assert!((metrics.offset_for_index(6).0 - 60.0).abs() < 0.01);
860        assert!((metrics.offset_for_index(100).0 - 1000.0).abs() < 0.01);
861
862        assert_eq!(metrics.index_for_offset(Px(0.0)), 0);
863        assert_eq!(metrics.index_for_offset(Px(0.1)), 0);
864        assert_eq!(metrics.index_for_offset(Px(9.9)), 0);
865        assert_eq!(metrics.index_for_offset(Px(10.0)), 1);
866        assert_eq!(metrics.index_for_offset(Px(59.9)), 5);
867        assert_eq!(metrics.index_for_offset(Px(60.0)), 6);
868        assert_eq!(metrics.end_index_for_offset(Px(50.0)), 5);
869        assert_eq!(metrics.end_index_for_offset(Px(50.1)), 6);
870    }
871
872    #[test]
873    fn visible_range_is_inclusive_and_clamped() {
874        let mut metrics = VirtualListMetrics::default();
875        metrics.ensure(10, Px(10.0), Px(0.0), Px(0.0));
876
877        let r0 = metrics.visible_range(Px(0.0), Px(25.0), 0).expect("range");
878        assert_eq!(r0.start_index, 0);
879        assert_eq!(r0.end_index, 2);
880        assert_eq!(r0.count, 10);
881
882        let r1 = metrics.visible_range(Px(50.0), Px(20.0), 0).expect("range");
883        assert_eq!(r1.start_index, 5);
884        assert_eq!(r1.end_index, 6);
885
886        assert!(metrics.visible_range(Px(0.0), Px(0.0), 0).is_none());
887
888        let mut empty = VirtualListMetrics::default();
889        empty.ensure(0, Px(10.0), Px(0.0), Px(0.0));
890        assert!(empty.visible_range(Px(0.0), Px(10.0), 0).is_none());
891    }
892
893    #[test]
894    fn prefetch_virtual_range_step_skips_when_both_edges_near_without_direction_hint() {
895        let rendered = VirtualRange {
896            start_index: 10,
897            end_index: 20,
898            overscan: 2,
899            count: 100,
900        };
901        let visible = VirtualRange {
902            start_index: 9,
903            end_index: 21,
904            overscan: 0,
905            count: 100,
906        };
907
908        let prefetch = prefetch_virtual_range_step(rendered, visible, 1, 3, None);
909        assert_eq!(prefetch, None);
910    }
911
912    #[test]
913    fn prefetch_virtual_range_step_avoids_oscillation_by_preferring_direction_hint() {
914        let rendered = VirtualRange {
915            start_index: 10,
916            end_index: 20,
917            overscan: 2,
918            count: 100,
919        };
920        let visible = VirtualRange {
921            start_index: 9,
922            end_index: 21,
923            overscan: 0,
924            count: 100,
925        };
926
927        let forward = prefetch_virtual_range_step(rendered, visible, 1, 3, Some(true));
928        assert_eq!(
929            forward,
930            Some(VirtualRange {
931                start_index: 11,
932                end_index: 21,
933                overscan: 2,
934                count: 100
935            })
936        );
937
938        let backward = prefetch_virtual_range_step(rendered, visible, 1, 3, Some(false));
939        assert_eq!(
940            backward,
941            Some(VirtualRange {
942                start_index: 9,
943                end_index: 19,
944                overscan: 2,
945                count: 100
946            })
947        );
948    }
949
950    #[test]
951    fn scroll_offset_for_item_matches_nearest_semantics() {
952        let mut metrics = VirtualListMetrics::default();
953        metrics.ensure(10, Px(10.0), Px(0.0), Px(0.0));
954
955        // Item fully visible -> keep current offset.
956        assert_eq!(
957            metrics.scroll_offset_for_item(2, Px(50.0), Px(0.0), ScrollStrategy::Nearest),
958            Px(0.0)
959        );
960
961        // Item above -> align to start.
962        assert_eq!(
963            metrics.scroll_offset_for_item(0, Px(20.0), Px(50.0), ScrollStrategy::Nearest),
964            Px(0.0)
965        );
966
967        // Item below -> align to end.
968        assert_eq!(
969            metrics.scroll_offset_for_item(9, Px(20.0), Px(0.0), ScrollStrategy::Nearest),
970            Px(80.0)
971        );
972    }
973
974    #[test]
975    fn fixed_mode_range_math_matches_uniform_metrics() {
976        let mut metrics = VirtualListMetrics::default();
977        metrics.ensure_with_mode(
978            VirtualListMeasureMode::Fixed,
979            100,
980            Px(10.0),
981            Px(0.0),
982            Px(0.0),
983        );
984
985        assert!((metrics.total_height().0 - 1000.0).abs() < 0.01);
986        assert!((metrics.offset_for_index(0).0 - 0.0).abs() < 0.01);
987        assert!((metrics.offset_for_index(6).0 - 60.0).abs() < 0.01);
988        assert!((metrics.offset_for_index(100).0 - 1000.0).abs() < 0.01);
989
990        assert_eq!(metrics.index_for_offset(Px(0.0)), 0);
991        assert_eq!(metrics.index_for_offset(Px(0.1)), 0);
992        assert_eq!(metrics.index_for_offset(Px(9.9)), 0);
993        assert_eq!(metrics.index_for_offset(Px(10.0)), 1);
994        assert_eq!(metrics.index_for_offset(Px(59.9)), 5);
995        assert_eq!(metrics.index_for_offset(Px(60.0)), 6);
996        assert_eq!(metrics.end_index_for_offset(Px(50.0)), 5);
997        assert_eq!(metrics.end_index_for_offset(Px(50.1)), 6);
998
999        let r0 = metrics.visible_range(Px(0.0), Px(25.0), 0).expect("range");
1000        assert_eq!(r0.start_index, 0);
1001        assert_eq!(r0.end_index, 2);
1002        assert_eq!(r0.count, 100);
1003    }
1004
1005    #[test]
1006    fn known_mode_can_import_fixed_per_index_heights() {
1007        let mut metrics = VirtualListMetrics::default();
1008        metrics.ensure_with_mode(VirtualListMeasureMode::Known, 3, Px(10.0), Px(2.0), Px(4.0));
1009        metrics.rebuild_from_known_heights(
1010            vec![Px(10.0), Px(20.0), Px(30.0)],
1011            Px(10.0),
1012            Px(2.0),
1013            Px(4.0),
1014        );
1015
1016        assert_eq!(metrics.height_at(0), Px(10.0));
1017        assert_eq!(metrics.height_at(1), Px(20.0));
1018        assert_eq!(metrics.height_at(2), Px(30.0));
1019
1020        // total = padding_start (4) + (10 + 20 + 30) + gaps (2*2) = 68
1021        assert_eq!(metrics.total_height(), Px(68.0));
1022
1023        assert_eq!(metrics.offset_for_index(0), Px(4.0));
1024        assert_eq!(metrics.offset_for_index(1), Px(16.0)); // 4 + 10 + 2
1025        assert_eq!(metrics.offset_for_index(2), Px(38.0)); // 4 + 10 + 2 + 20 + 2
1026    }
1027}