Skip to main content

window_core/
lib.rs

1const MIN_ITEM_HEIGHT: f64 = 1.0;
2const HEIGHT_EPSILON: f64 = 0.5;
3
4#[derive(Clone, Copy, Debug, PartialEq)]
5pub struct VisibleRange {
6    pub start: usize,
7    pub end: usize,
8    pub pad_top: f64,
9    pub pad_bottom: f64,
10    pub total_height: f64,
11}
12
13#[derive(Clone, Debug, PartialEq)]
14pub struct HeightCacheSnapshot {
15    pub estimate: f64,
16    pub measured: Vec<(usize, f64)>,
17}
18
19#[derive(Clone, Debug, PartialEq)]
20pub struct VirtualWindowConfig {
21    pub item_count: usize,
22    pub estimated_item_height: f64,
23    pub overscan_px: f64,
24    pub overscan_items: usize,
25    pub sticky_bottom_threshold_px: f64,
26    pub trailing_shrink_limit_items: usize,
27    pub min_stable_overscan_viewport_factor: f64,
28}
29
30impl Default for VirtualWindowConfig {
31    fn default() -> Self {
32        Self {
33            item_count: 0,
34            estimated_item_height: 80.0,
35            overscan_px: 260.0,
36            overscan_items: 3,
37            sticky_bottom_threshold_px: 96.0,
38            trailing_shrink_limit_items: 3,
39            min_stable_overscan_viewport_factor: 0.6,
40        }
41    }
42}
43
44#[derive(Clone, Debug, PartialEq)]
45pub enum WindowEvent {
46    Scroll { top: f64 },
47    ResizeViewport { height: f64 },
48    MeasureItem { index: usize, height: f64 },
49    SetItemCount { count: usize },
50    PrependItems { count: usize },
51    AppendItems { count: usize },
52    SetStickToBottom { enabled: bool },
53    RestoreHeights(HeightCacheSnapshot),
54}
55
56#[derive(Clone, Copy, Debug, PartialEq)]
57pub struct WindowUpdate {
58    pub range: VisibleRange,
59    pub total_height: f64,
60    pub scroll_top: f64,
61    pub viewport_height: f64,
62    pub distance_to_bottom: f64,
63    pub should_stick_to_bottom: bool,
64    pub scroll_to: Option<f64>,
65    pub changed: bool,
66}
67
68#[derive(Clone, Copy, Debug, PartialEq)]
69struct Anchor {
70    index: usize,
71    offset_within: f64,
72}
73
74#[derive(Clone, Copy, Debug, Eq, PartialEq)]
75enum ScrollTrend {
76    Idle,
77    Up,
78    Down,
79}
80
81#[derive(Clone, Debug)]
82pub struct VirtualWindow {
83    config: VirtualWindowConfig,
84    heights: HeightCache,
85    scroll_top: f64,
86    viewport_height: f64,
87    stick_to_bottom_mode: bool,
88    should_stick_to_bottom: bool,
89    range: VisibleRange,
90    last_scroll_top: Option<f64>,
91    last_range: Option<(usize, usize)>,
92}
93
94impl VirtualWindow {
95    pub fn new(config: VirtualWindowConfig) -> Self {
96        let estimate = config.estimated_item_height.max(MIN_ITEM_HEIGHT);
97        let item_count = config.item_count;
98        let mut window = Self {
99            config: VirtualWindowConfig {
100                estimated_item_height: estimate,
101                overscan_px: config.overscan_px.max(0.0),
102                overscan_items: config.overscan_items,
103                sticky_bottom_threshold_px: config.sticky_bottom_threshold_px.max(0.0),
104                trailing_shrink_limit_items: config.trailing_shrink_limit_items,
105                min_stable_overscan_viewport_factor: config
106                    .min_stable_overscan_viewport_factor
107                    .max(0.0),
108                item_count,
109            },
110            heights: HeightCache::new(item_count, estimate),
111            scroll_top: 0.0,
112            viewport_height: estimate,
113            stick_to_bottom_mode: true,
114            should_stick_to_bottom: true,
115            range: VisibleRange {
116                start: 0,
117                end: 0,
118                pad_top: 0.0,
119                pad_bottom: 0.0,
120                total_height: 0.0,
121            },
122            last_scroll_top: None,
123            last_range: None,
124        };
125        window.range = window.compute_stable_range();
126        window.should_stick_to_bottom =
127            window.distance_to_bottom_internal() <= window.config.sticky_bottom_threshold_px;
128        window
129    }
130
131    pub fn update(&mut self, event: WindowEvent) -> WindowUpdate {
132        let before_range = self.range;
133        let before_total = self.total_height();
134        let before_scroll_top = self.scroll_top;
135        let before_viewport_height = self.viewport_height;
136        let before_should_stick = self.should_stick_to_bottom;
137
138        let mut scroll_to = None;
139
140        match event {
141            WindowEvent::Scroll { top } => {
142                self.scroll_top = self.clamp_scroll_top(top);
143                self.should_stick_to_bottom =
144                    self.distance_to_bottom_internal() <= self.config.sticky_bottom_threshold_px;
145                self.stick_to_bottom_mode = self.should_stick_to_bottom;
146            }
147            WindowEvent::ResizeViewport { height } => {
148                self.viewport_height = self.normalize_viewport_height(height);
149                if self.stick_to_bottom_mode {
150                    let target = self.max_scroll_top_internal();
151                    if (target - self.scroll_top).abs() > 0.1 {
152                        self.scroll_top = target;
153                        scroll_to = Some(target);
154                    } else {
155                        self.scroll_top = target;
156                    }
157                } else {
158                    let clamped = self.clamp_scroll_top(self.scroll_top);
159                    if (clamped - self.scroll_top).abs() > 0.1 {
160                        self.scroll_top = clamped;
161                        scroll_to = Some(clamped);
162                    } else {
163                        self.scroll_top = clamped;
164                    }
165                }
166                self.should_stick_to_bottom =
167                    self.distance_to_bottom_internal() <= self.config.sticky_bottom_threshold_px;
168            }
169            WindowEvent::MeasureItem { index, height } => {
170                let anchor = self.capture_anchor();
171                let delta = self.heights.set_height(index, height);
172                if delta.abs() > HEIGHT_EPSILON {
173                    if self.stick_to_bottom_mode {
174                        let target = self.max_scroll_top_internal();
175                        if (target - self.scroll_top).abs() > 0.1 {
176                            self.scroll_top = target;
177                            scroll_to = Some(target);
178                        }
179                    } else if index < anchor.index {
180                        let target = self.clamp_scroll_top(self.scroll_top + delta);
181                        if (target - self.scroll_top).abs() > 0.1 {
182                            self.scroll_top = target;
183                            scroll_to = Some(target);
184                        }
185                    }
186                }
187                self.should_stick_to_bottom =
188                    self.distance_to_bottom_internal() <= self.config.sticky_bottom_threshold_px;
189            }
190            WindowEvent::SetItemCount { count } => {
191                self.heights.set_count(count);
192                self.config.item_count = self.heights.len();
193                self.clear_range_history();
194                self.scroll_top = self.clamp_scroll_top(self.scroll_top);
195                self.should_stick_to_bottom =
196                    self.distance_to_bottom_internal() <= self.config.sticky_bottom_threshold_px;
197            }
198            WindowEvent::PrependItems { count } => {
199                if count > 0 {
200                    let anchor = self.capture_anchor();
201                    self.heights.prepend(count);
202                    self.config.item_count = self.heights.len();
203                    self.clear_range_history();
204                    let target = self.restore_anchor(anchor, count as isize);
205                    self.scroll_top = self.clamp_scroll_top(target);
206                    scroll_to = Some(self.scroll_top);
207                    self.should_stick_to_bottom = self.distance_to_bottom_internal()
208                        <= self.config.sticky_bottom_threshold_px;
209                }
210            }
211            WindowEvent::AppendItems { count } => {
212                if count > 0 {
213                    self.heights.append(count);
214                    self.config.item_count = self.heights.len();
215                    if self.stick_to_bottom_mode {
216                        let target = self.max_scroll_top_internal();
217                        self.scroll_top = target;
218                        scroll_to = Some(target);
219                    } else {
220                        self.scroll_top = self.clamp_scroll_top(self.scroll_top);
221                    }
222                    self.should_stick_to_bottom = self.distance_to_bottom_internal()
223                        <= self.config.sticky_bottom_threshold_px;
224                }
225            }
226            WindowEvent::SetStickToBottom { enabled } => {
227                self.stick_to_bottom_mode = enabled;
228                if enabled {
229                    let target = self.max_scroll_top_internal();
230                    if (target - self.scroll_top).abs() > 0.1 {
231                        self.scroll_top = target;
232                        scroll_to = Some(target);
233                    } else {
234                        self.scroll_top = target;
235                    }
236                }
237                self.should_stick_to_bottom =
238                    self.distance_to_bottom_internal() <= self.config.sticky_bottom_threshold_px;
239            }
240            WindowEvent::RestoreHeights(snapshot) => {
241                self.heights.restore_snapshot(&snapshot);
242                self.config.item_count = self.heights.len();
243                self.clear_range_history();
244                if self.stick_to_bottom_mode {
245                    let target = self.max_scroll_top_internal();
246                    if (target - self.scroll_top).abs() > 0.1 {
247                        self.scroll_top = target;
248                        scroll_to = Some(target);
249                    } else {
250                        self.scroll_top = target;
251                    }
252                } else {
253                    self.scroll_top = self.clamp_scroll_top(self.scroll_top);
254                }
255                self.should_stick_to_bottom =
256                    self.distance_to_bottom_internal() <= self.config.sticky_bottom_threshold_px;
257            }
258        }
259
260        self.range = self.compute_stable_range();
261
262        let changed = scroll_to.is_some()
263            || before_range != self.range
264            || (before_total - self.total_height()).abs() > 0.1
265            || (before_scroll_top - self.scroll_top).abs() > 0.1
266            || (before_viewport_height - self.viewport_height).abs() > 0.1
267            || before_should_stick != self.should_stick_to_bottom;
268
269        WindowUpdate {
270            range: self.range,
271            total_height: self.total_height(),
272            scroll_top: self.scroll_top,
273            viewport_height: self.viewport_height,
274            distance_to_bottom: self.distance_to_bottom_internal(),
275            should_stick_to_bottom: self.should_stick_to_bottom,
276            scroll_to,
277            changed,
278        }
279    }
280
281    pub fn visible_range(&self) -> VisibleRange {
282        self.range
283    }
284
285    pub fn offset_of(&self, index: usize) -> f64 {
286        self.heights.offset_of(index)
287    }
288
289    pub fn item_height(&self, index: usize) -> f64 {
290        self.heights.height_at(index)
291    }
292
293    pub fn total_height(&self) -> f64 {
294        self.heights.total_height()
295    }
296
297    pub fn snapshot_heights(&self) -> HeightCacheSnapshot {
298        self.heights.snapshot()
299    }
300
301    fn normalize_viewport_height(&self, height: f64) -> f64 {
302        if height.is_finite() {
303            height.max(MIN_ITEM_HEIGHT)
304        } else {
305            MIN_ITEM_HEIGHT
306        }
307    }
308
309    fn max_scroll_top_internal(&self) -> f64 {
310        (self.total_height() - self.viewport_height.max(0.0)).max(0.0)
311    }
312
313    fn clamp_scroll_top(&self, top: f64) -> f64 {
314        let top = if top.is_finite() { top.max(0.0) } else { 0.0 };
315        top.min(self.max_scroll_top_internal())
316    }
317
318    fn distance_to_bottom_internal(&self) -> f64 {
319        (self.max_scroll_top_internal() - self.scroll_top).max(0.0)
320    }
321
322    fn clear_range_history(&mut self) {
323        self.last_scroll_top = None;
324        self.last_range = None;
325    }
326
327    fn capture_anchor(&self) -> Anchor {
328        if self.heights.len() == 0 {
329            return Anchor {
330                index: 0,
331                offset_within: 0.0,
332            };
333        }
334        let index = self.heights.index_at_offset(self.scroll_top.max(0.0));
335        let offset_within = (self.scroll_top - self.heights.offset_of(index)).max(0.0);
336        Anchor {
337            index,
338            offset_within,
339        }
340    }
341
342    fn restore_anchor(&self, anchor: Anchor, index_shift: isize) -> f64 {
343        if self.heights.len() == 0 {
344            return 0.0;
345        }
346
347        let shifted = if index_shift >= 0 {
348            anchor.index.saturating_add(index_shift as usize)
349        } else {
350            anchor.index.saturating_sub(index_shift.unsigned_abs())
351        };
352        let index = shifted.min(self.heights.len() - 1);
353        let offset_limit = (self.heights.height_at(index) - 1.0).max(0.0);
354        let offset_within = anchor.offset_within.min(offset_limit);
355        (self.heights.offset_of(index) + offset_within).max(0.0)
356    }
357
358    fn compute_stable_range(&mut self) -> VisibleRange {
359        let viewport = self.normalize_viewport_height(self.viewport_height);
360        let scroll_top = self.clamp_scroll_top(self.scroll_top);
361
362        let previous_scroll_top = self.last_scroll_top.unwrap_or(scroll_top);
363        let delta = scroll_top - previous_scroll_top;
364        let speed_px = delta.abs();
365        let trend = if delta > 0.5 {
366            ScrollTrend::Down
367        } else if delta < -0.5 {
368            ScrollTrend::Up
369        } else {
370            ScrollTrend::Idle
371        };
372
373        let estimate = self
374            .heights
375            .estimate
376            .max(self.config.estimated_item_height)
377            .max(MIN_ITEM_HEIGHT);
378        let speed_items = ((speed_px / estimate).ceil() as usize).min(64);
379        let base_item_floor = ((viewport / estimate).ceil() as usize / 2).max(2);
380        let min_px = viewport * self.config.min_stable_overscan_viewport_factor;
381
382        let mut before_px = self.config.overscan_px.max(min_px);
383        let mut after_px = self.config.overscan_px.max(min_px);
384        let mut before_items = self.config.overscan_items.max(base_item_floor);
385        let mut after_items = self.config.overscan_items.max(base_item_floor);
386
387        match trend {
388            ScrollTrend::Down => {
389                after_px += speed_px * 1.25;
390                after_items = after_items.saturating_add(speed_items);
391            }
392            ScrollTrend::Up => {
393                before_px += speed_px * 1.25;
394                before_items = before_items.saturating_add(speed_items);
395            }
396            ScrollTrend::Idle => {}
397        }
398
399        let mut next = self.compute_range_with_strategy(
400            scroll_top,
401            viewport,
402            before_px,
403            after_px,
404            before_items,
405            after_items,
406        );
407
408        if let Some((prev_start, prev_end)) = self.last_range {
409            let shrink_limit = self.config.trailing_shrink_limit_items;
410            match trend {
411                ScrollTrend::Down => {
412                    let max_start = prev_start.saturating_add(shrink_limit);
413                    if next.start > max_start {
414                        next.start = max_start;
415                    }
416                }
417                ScrollTrend::Up => {
418                    let min_end = prev_end.saturating_sub(shrink_limit);
419                    if next.end < min_end {
420                        next.end = min_end;
421                    }
422                }
423                ScrollTrend::Idle => {
424                    let max_start = prev_start.saturating_add(shrink_limit.saturating_mul(2));
425                    let min_end = prev_end.saturating_sub(shrink_limit.saturating_mul(2));
426                    if next.start > max_start {
427                        next.start = max_start;
428                    }
429                    if next.end < min_end {
430                        next.end = min_end;
431                    }
432                }
433            }
434
435            let len = self.heights.len();
436            if next.end > len {
437                next.end = len;
438            }
439            if next.start >= next.end && len > 0 {
440                next.start = next.end.saturating_sub(1);
441            }
442        }
443
444        next.pad_top = self.heights.offset_of(next.start);
445        next.pad_bottom = (next.total_height - self.heights.offset_of(next.end)).max(0.0);
446
447        self.last_scroll_top = Some(scroll_top);
448        self.last_range = Some((next.start, next.end));
449        next
450    }
451
452    fn compute_range_with_strategy(
453        &self,
454        scroll_top: f64,
455        viewport_height: f64,
456        overscan_before_px: f64,
457        overscan_after_px: f64,
458        overscan_before_items: usize,
459        overscan_after_items: usize,
460    ) -> VisibleRange {
461        let len = self.heights.len();
462        let total = self.total_height();
463
464        if len == 0 {
465            return VisibleRange {
466                start: 0,
467                end: 0,
468                pad_top: 0.0,
469                pad_bottom: 0.0,
470                total_height: total,
471            };
472        }
473
474        let safe_viewport_height = if viewport_height.is_finite() {
475            viewport_height.max(MIN_ITEM_HEIGHT)
476        } else {
477            MIN_ITEM_HEIGHT
478        };
479
480        let top = if scroll_top.is_finite() {
481            scroll_top.max(0.0)
482        } else {
483            0.0
484        };
485        let visible_end = (top + safe_viewport_height).min(total);
486        let overscan_start_offset = (top - overscan_before_px.max(0.0)).max(0.0);
487        let overscan_end_offset = (visible_end + overscan_after_px.max(0.0)).min(total);
488
489        let start_visible = self.heights.index_at_offset(top);
490        let stop_visible = self.heights.index_at_offset(visible_end);
491        let mut start = self.heights.index_at_offset(overscan_start_offset);
492        let mut end = self
493            .heights
494            .index_at_offset(overscan_end_offset)
495            .saturating_add(1)
496            .min(len);
497
498        let start_floor = start_visible.saturating_sub(overscan_before_items);
499        let end_floor = stop_visible
500            .saturating_add(1)
501            .saturating_add(overscan_after_items)
502            .min(len);
503
504        if start > start_floor {
505            start = start_floor;
506        }
507        if end < end_floor {
508            end = end_floor;
509        }
510
511        VisibleRange {
512            start,
513            end,
514            pad_top: self.heights.offset_of(start),
515            pad_bottom: (total - self.heights.offset_of(end)).max(0.0),
516            total_height: total,
517        }
518    }
519}
520
521#[derive(Clone, Debug)]
522struct HeightCache {
523    estimate: f64,
524    measured_sum: f64,
525    measured_count: usize,
526    values: Vec<f64>,
527    measured: Vec<bool>,
528    fenwick: Fenwick,
529}
530
531impl HeightCache {
532    fn new(count: usize, estimate: f64) -> Self {
533        let values = vec![estimate; count];
534        let measured = vec![false; count];
535        let fenwick = Fenwick::from_values(&values);
536        Self {
537            estimate,
538            measured_sum: 0.0,
539            measured_count: 0,
540            values,
541            measured,
542            fenwick,
543        }
544    }
545
546    fn len(&self) -> usize {
547        self.values.len()
548    }
549
550    fn append(&mut self, count: usize) {
551        if count == 0 {
552            return;
553        }
554        self.values
555            .resize(self.values.len().saturating_add(count), self.estimate);
556        self.measured
557            .resize(self.measured.len().saturating_add(count), false);
558        self.fenwick = Fenwick::from_values(&self.values);
559    }
560
561    fn prepend(&mut self, count: usize) {
562        if count == 0 {
563            return;
564        }
565
566        let mut values = vec![self.estimate; count];
567        values.extend(self.values.iter().copied());
568        self.values = values;
569
570        let mut measured = vec![false; count];
571        measured.extend(self.measured.iter().copied());
572        self.measured = measured;
573
574        self.fenwick = Fenwick::from_values(&self.values);
575    }
576
577    fn set_count(&mut self, count: usize) {
578        let old_len = self.values.len();
579        if count < old_len {
580            for index in count..old_len {
581                if self.measured[index] {
582                    self.measured_count = self.measured_count.saturating_sub(1);
583                    self.measured_sum -= self.values[index];
584                }
585            }
586            self.values.truncate(count);
587            self.measured.truncate(count);
588        } else if count > old_len {
589            self.values.resize(count, self.estimate);
590            self.measured.resize(count, false);
591        }
592        if self.measured_count == 0 {
593            self.measured_sum = 0.0;
594        }
595        self.fenwick = Fenwick::from_values(&self.values);
596    }
597
598    fn set_height(&mut self, index: usize, measured_height: f64) -> f64 {
599        if index >= self.values.len() {
600            return 0.0;
601        }
602
603        let current = self.values[index];
604        let next = measured_height.max(MIN_ITEM_HEIGHT);
605        let delta = next - current;
606        if delta.abs() <= HEIGHT_EPSILON {
607            return 0.0;
608        }
609
610        let was_measured = self.measured[index];
611        self.values[index] = next;
612        if was_measured {
613            self.measured_sum += delta;
614        } else {
615            self.measured[index] = true;
616            self.measured_count += 1;
617            self.measured_sum += next;
618        }
619        if self.measured_count > 0 {
620            self.estimate = (self.measured_sum / self.measured_count as f64).max(MIN_ITEM_HEIGHT);
621        }
622        self.fenwick.add(index, delta);
623        delta
624    }
625
626    fn total_height(&self) -> f64 {
627        self.fenwick.total()
628    }
629
630    fn height_at(&self, index: usize) -> f64 {
631        self.values.get(index).copied().unwrap_or(self.estimate)
632    }
633
634    fn offset_of(&self, index: usize) -> f64 {
635        self.fenwick.prefix_sum(index)
636    }
637
638    fn index_at_offset(&self, offset: f64) -> usize {
639        let len = self.len();
640        if len == 0 {
641            return 0;
642        }
643
644        let total = self.total_height();
645        if offset <= 0.0 {
646            return 0;
647        }
648        if offset >= total {
649            return len - 1;
650        }
651
652        self.fenwick.upper_bound_prefix(offset).min(len - 1)
653    }
654
655    fn snapshot(&self) -> HeightCacheSnapshot {
656        let measured = self
657            .measured
658            .iter()
659            .enumerate()
660            .filter_map(|(index, measured)| measured.then_some((index, self.values[index])))
661            .collect();
662        HeightCacheSnapshot {
663            estimate: self.estimate,
664            measured,
665        }
666    }
667
668    fn restore_snapshot(&mut self, snapshot: &HeightCacheSnapshot) {
669        self.estimate = snapshot.estimate.max(MIN_ITEM_HEIGHT);
670        self.values.fill(self.estimate);
671        self.measured.fill(false);
672        self.measured_sum = 0.0;
673        self.measured_count = 0;
674
675        for &(index, height) in &snapshot.measured {
676            if index >= self.values.len() {
677                continue;
678            }
679            let clamped_height = height.max(MIN_ITEM_HEIGHT);
680            self.values[index] = clamped_height;
681            self.measured[index] = true;
682            self.measured_sum += clamped_height;
683            self.measured_count += 1;
684        }
685
686        if self.measured_count > 0 {
687            self.estimate = (self.measured_sum / self.measured_count as f64).max(MIN_ITEM_HEIGHT);
688        }
689
690        self.fenwick = Fenwick::from_values(&self.values);
691    }
692}
693
694#[derive(Clone, Debug)]
695struct Fenwick {
696    tree: Vec<f64>,
697}
698
699impl Fenwick {
700    fn from_values(values: &[f64]) -> Self {
701        let mut fenwick = Self {
702            tree: vec![0.0; values.len() + 1],
703        };
704        for (index, value) in values.iter().copied().enumerate() {
705            fenwick.add(index, value);
706        }
707        fenwick
708    }
709
710    fn len(&self) -> usize {
711        self.tree.len().saturating_sub(1)
712    }
713
714    fn total(&self) -> f64 {
715        self.prefix_sum(self.len())
716    }
717
718    fn add(&mut self, index: usize, delta: f64) {
719        if self.len() == 0 || index >= self.len() {
720            return;
721        }
722        let mut i = index + 1;
723        while i < self.tree.len() {
724            self.tree[i] += delta;
725            i += lowbit(i);
726        }
727    }
728
729    fn prefix_sum(&self, end_exclusive: usize) -> f64 {
730        let mut i = end_exclusive.min(self.len());
731        let mut acc = 0.0;
732        while i > 0 {
733            acc += self.tree[i];
734            i -= lowbit(i);
735        }
736        acc
737    }
738
739    fn upper_bound_prefix(&self, target: f64) -> usize {
740        let n = self.len();
741        if n == 0 {
742            return 0;
743        }
744
745        let mut bit = 1usize;
746        while bit < n {
747            bit <<= 1;
748        }
749
750        let mut index = 0usize;
751        let mut sum = 0.0f64;
752
753        while bit != 0 {
754            let next = index + bit;
755            if next <= n && sum + self.tree[next] <= target {
756                index = next;
757                sum += self.tree[next];
758            }
759            bit >>= 1;
760        }
761
762        index
763    }
764}
765
766#[inline]
767fn lowbit(value: usize) -> usize {
768    value & value.wrapping_neg()
769}
770
771#[cfg(test)]
772mod tests {
773    use super::{VirtualWindow, VirtualWindowConfig, WindowEvent};
774
775    fn config(count: usize) -> VirtualWindowConfig {
776        VirtualWindowConfig {
777            item_count: count,
778            estimated_item_height: 20.0,
779            overscan_px: 80.0,
780            overscan_items: 3,
781            sticky_bottom_threshold_px: 12.0,
782            trailing_shrink_limit_items: 3,
783            min_stable_overscan_viewport_factor: 0.6,
784        }
785    }
786
787    #[test]
788    fn range_invariants_hold_for_non_empty_window() {
789        let mut window = VirtualWindow::new(config(200));
790        window.update(WindowEvent::ResizeViewport { height: 220.0 });
791
792        for step in 0..200 {
793            let top = (step as f64 * 37.0) % 3_000.0;
794            let update = window.update(WindowEvent::Scroll { top });
795            assert!(update.range.start <= update.range.end);
796            assert!(update.range.end <= 200);
797            assert!(update.range.end > 0);
798            if update.range.start < update.range.end {
799                assert!(update.range.start < 200);
800            }
801        }
802    }
803
804    #[test]
805    fn prepend_items_preserves_anchor_position() {
806        let mut window = VirtualWindow::new(config(100));
807        window.update(WindowEvent::ResizeViewport { height: 200.0 });
808        let before = window.update(WindowEvent::Scroll { top: 430.0 });
809
810        let update = window.update(WindowEvent::PrependItems { count: 30 });
811        assert!(update.scroll_to.is_some());
812        assert!(update.scroll_top > before.scroll_top + 550.0);
813        assert!(update.range.end <= 130);
814    }
815
816    #[test]
817    fn sticky_bottom_resize_emits_scroll_correction() {
818        let mut window = VirtualWindow::new(config(10));
819        window.update(WindowEvent::ResizeViewport { height: 100.0 });
820        let bottom_top = window.total_height() - 100.0;
821        window.update(WindowEvent::Scroll { top: bottom_top });
822
823        let update = window.update(WindowEvent::ResizeViewport { height: 80.0 });
824        assert!(update.scroll_to.is_some());
825        assert!((update.scroll_top - (window.total_height() - 80.0)).abs() < 0.1);
826    }
827
828    #[test]
829    fn measure_item_only_adjusts_scroll_when_item_is_above_anchor() {
830        let mut window = VirtualWindow::new(config(50));
831        window.update(WindowEvent::ResizeViewport { height: 200.0 });
832        window.update(WindowEvent::SetStickToBottom { enabled: false });
833        window.update(WindowEvent::Scroll { top: 260.0 });
834
835        let above = window.update(WindowEvent::MeasureItem {
836            index: 2,
837            height: 60.0,
838        });
839        assert!(above.scroll_to.is_some());
840
841        let below = window.update(WindowEvent::MeasureItem {
842            index: 25,
843            height: 55.0,
844        });
845        assert!(below.scroll_to.is_none());
846    }
847
848    #[test]
849    fn set_item_count_resets_unstable_range_and_bounds() {
850        let mut window = VirtualWindow::new(config(300));
851        window.update(WindowEvent::ResizeViewport { height: 260.0 });
852        window.update(WindowEvent::Scroll { top: 3_000.0 });
853
854        let update = window.update(WindowEvent::SetItemCount { count: 5 });
855        assert!(update.range.end <= 5);
856        assert!(update.range.start <= update.range.end);
857
858        let after_scroll = window.update(WindowEvent::Scroll { top: 1_000.0 });
859        assert!(after_scroll.range.end <= 5);
860        assert!(after_scroll.range.start <= after_scroll.range.end);
861    }
862
863    #[test]
864    fn append_items_respects_stick_to_bottom_mode() {
865        let mut window = VirtualWindow::new(config(20));
866        window.update(WindowEvent::ResizeViewport { height: 200.0 });
867        window.update(WindowEvent::Scroll { top: 200.0 });
868        window.update(WindowEvent::SetStickToBottom { enabled: true });
869
870        let update = window.update(WindowEvent::AppendItems { count: 5 });
871        assert!(update.scroll_to.is_some());
872        assert!(update.should_stick_to_bottom);
873    }
874}