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}