1#![forbid(unsafe_code)]
2
3use std::cell::Cell as StdCell;
34use std::collections::VecDeque;
35use std::ops::Range;
36use std::time::Duration;
37
38use crate::scrollbar::{Scrollbar, ScrollbarOrientation, ScrollbarState};
39use crate::{StatefulWidget, clear_text_area};
40use ftui_core::geometry::Rect;
41use ftui_render::cell::Cell;
42use ftui_render::frame::Frame;
43use ftui_style::Style;
44
45#[derive(Debug, Clone)]
54pub struct Virtualized<T> {
55 storage: VirtualizedStorage<T>,
57 scroll_offset: usize,
59 visible_count: StdCell<usize>,
61 overscan: usize,
63 item_height: ItemHeight,
65 follow_mode: bool,
67 scroll_velocity: f32,
69}
70
71#[derive(Debug, Clone)]
73pub enum VirtualizedStorage<T> {
74 Owned(VecDeque<T>),
76 External {
79 len: usize,
81 cache_capacity: usize,
83 },
84}
85
86#[derive(Debug, Clone)]
88pub enum ItemHeight {
89 Fixed(u16),
91 Variable(HeightCache),
93 VariableFenwick(VariableHeightsFenwick),
95}
96
97#[derive(Debug, Clone)]
99pub struct HeightCache {
100 cache: Vec<Option<u16>>,
102 base_offset: usize,
104 default_height: u16,
106 capacity: usize,
108}
109
110impl<T> Virtualized<T> {
111 #[must_use]
116 pub fn new(capacity: usize) -> Self {
117 Self {
118 storage: VirtualizedStorage::Owned(VecDeque::with_capacity(capacity.min(1024))),
119 scroll_offset: 0,
120 visible_count: StdCell::new(0),
121 overscan: 2,
122 item_height: ItemHeight::Fixed(1),
123 follow_mode: false,
124 scroll_velocity: 0.0,
125 }
126 }
127
128 #[must_use]
130 pub fn external(len: usize, cache_capacity: usize) -> Self {
131 Self {
132 storage: VirtualizedStorage::External {
133 len,
134 cache_capacity,
135 },
136 scroll_offset: 0,
137 visible_count: StdCell::new(0),
138 overscan: 2,
139 item_height: ItemHeight::Fixed(1),
140 follow_mode: false,
141 scroll_velocity: 0.0,
142 }
143 }
144
145 #[must_use]
147 pub fn with_item_height(mut self, height: ItemHeight) -> Self {
148 self.item_height = height;
149 self
150 }
151
152 #[must_use]
154 pub fn with_fixed_height(mut self, height: u16) -> Self {
155 self.item_height = ItemHeight::Fixed(height);
156 self
157 }
158
159 #[must_use]
164 pub fn with_variable_heights_fenwick(mut self, default_height: u16, capacity: usize) -> Self {
165 self.item_height =
166 ItemHeight::VariableFenwick(VariableHeightsFenwick::new(default_height, capacity));
167 self
168 }
169
170 #[must_use]
172 pub fn with_overscan(mut self, overscan: usize) -> Self {
173 self.overscan = overscan;
174 self
175 }
176
177 #[must_use]
179 pub fn with_follow(mut self, follow: bool) -> Self {
180 self.follow_mode = follow;
181 self
182 }
183
184 #[must_use]
186 pub fn len(&self) -> usize {
187 match &self.storage {
188 VirtualizedStorage::Owned(items) => items.len(),
189 VirtualizedStorage::External { len, .. } => *len,
190 }
191 }
192
193 #[must_use]
195 pub fn is_empty(&self) -> bool {
196 self.len() == 0
197 }
198
199 #[must_use]
204 pub fn scroll_offset(&self) -> usize {
205 self.scroll_offset.min(self.len().saturating_sub(1))
206 }
207
208 #[must_use]
210 pub fn visible_count(&self) -> usize {
211 self.visible_count.get()
212 }
213
214 #[must_use]
216 pub fn follow_mode(&self) -> bool {
217 self.follow_mode
218 }
219
220 #[must_use]
222 pub fn visible_range(&self, viewport_height: u16) -> Range<usize> {
223 if self.is_empty() || viewport_height == 0 {
224 self.visible_count.set(0);
225 return 0..0;
226 }
227
228 let items_visible = match &self.item_height {
229 ItemHeight::Fixed(h) if *h > 0 => viewport_height.div_ceil(*h) as usize,
231 ItemHeight::Fixed(_) => viewport_height as usize,
232 ItemHeight::Variable(cache) => {
233 let mut count = 0;
235 let mut total_height = 0u16;
236 let start = self.scroll_offset.min(self.len().saturating_sub(1));
237 while start + count < self.len() {
238 let next = cache.get(start + count);
239 let proposed = total_height.saturating_add(next);
240
241 total_height = proposed;
243 count += 1;
244
245 if total_height >= viewport_height {
247 break;
248 }
249 }
250 count
251 }
252 ItemHeight::VariableFenwick(tracker) => {
253 tracker.visible_count(self.scroll_offset, viewport_height)
255 }
256 };
257
258 let max_offset = self.len().saturating_sub(items_visible);
259 let start = self.scroll_offset.min(max_offset);
260 let end = start.saturating_add(items_visible).min(self.len());
261 self.visible_count.set(items_visible);
262 start..end
263 }
264
265 #[must_use]
267 pub fn render_range(&self, viewport_height: u16) -> Range<usize> {
268 let visible = self.visible_range(viewport_height);
269 let start = visible.start.saturating_sub(self.overscan);
270 let end = visible.end.saturating_add(self.overscan).min(self.len());
271 start..end
272 }
273
274 pub fn scroll(&mut self, delta: i32) {
276 if self.is_empty() {
277 return;
278 }
279 let visible_count = self.visible_count.get();
280 let max_offset = if visible_count > 0 {
281 self.len().saturating_sub(visible_count)
282 } else {
283 self.len().saturating_sub(1)
284 };
285 let clamped_current = self.scroll_offset.min(max_offset);
287 let new_offset = clamped_current
288 .saturating_add_signed(delta as isize)
289 .min(max_offset);
290 self.scroll_offset = new_offset;
291
292 if delta != 0 {
294 self.follow_mode = false;
295 }
296 }
297
298 pub fn scroll_to(&mut self, idx: usize) {
300 self.scroll_offset = idx.min(self.len().saturating_sub(1));
301 self.follow_mode = false;
302 }
303
304 pub fn scroll_to_bottom(&mut self) {
306 if self.is_empty() {
307 self.scroll_offset = 0;
308 return;
309 }
310
311 let visible_count = self.visible_count.get();
312 if visible_count == 0 {
313 self.scroll_offset = usize::MAX;
315 } else if self.len() > visible_count {
316 self.scroll_offset = self.len().saturating_sub(visible_count);
317 } else {
318 self.scroll_offset = 0;
319 }
320 }
321
322 pub fn scroll_to_top(&mut self) {
324 self.scroll_offset = 0;
325 self.follow_mode = false;
326 }
327
328 pub fn scroll_to_start(&mut self) {
330 self.scroll_to_top();
331 }
332
333 pub fn scroll_to_end(&mut self) {
335 self.scroll_to_bottom();
336 self.follow_mode = true;
337 }
338
339 pub fn page_up(&mut self) {
341 let visible_count = self.visible_count.get();
342 if visible_count > 0 {
343 let step = if visible_count > 1 {
344 visible_count - 1
345 } else {
346 1
347 };
348 let delta = i32::try_from(step).unwrap_or(i32::MAX);
349 self.scroll(-delta);
350 }
351 }
352
353 pub fn page_down(&mut self) {
355 let visible_count = self.visible_count.get();
356 if visible_count > 0 {
357 let step = if visible_count > 1 {
358 visible_count - 1
359 } else {
360 1
361 };
362 let delta = i32::try_from(step).unwrap_or(i32::MAX);
363 self.scroll(delta);
364 }
365 }
366
367 pub fn set_follow(&mut self, follow: bool) {
369 self.follow_mode = follow;
370 if follow {
371 self.scroll_to_bottom();
372 }
373 }
374
375 #[must_use]
377 pub fn is_at_bottom(&self) -> bool {
378 let visible_count = self.visible_count.get();
379 if self.len() <= visible_count {
380 true
381 } else {
382 self.scroll_offset >= self.len().saturating_sub(visible_count)
383 }
384 }
385
386 pub fn fling(&mut self, velocity: f32) {
388 self.scroll_velocity = velocity;
389 }
390
391 pub fn tick(&mut self, dt: Duration) {
393 if self.scroll_velocity.abs() > 0.1 {
394 let delta = (self.scroll_velocity * dt.as_secs_f32()) as i32;
395 if delta != 0 {
396 self.scroll(delta);
397 }
398 self.scroll_velocity *= 0.95;
400 } else {
401 self.scroll_velocity = 0.0;
402 }
403 }
404
405 pub fn set_visible_count(&self, count: usize) {
407 self.visible_count.set(count);
408 }
409
410 #[must_use]
412 pub fn item_height(&self) -> &ItemHeight {
413 &self.item_height
414 }
415
416 pub fn item_height_mut(&mut self) -> &mut ItemHeight {
421 &mut self.item_height
422 }
423}
424
425impl<T> Virtualized<T> {
426 pub fn push(&mut self, item: T) {
428 if let VirtualizedStorage::Owned(items) = &mut self.storage {
429 items.push_back(item);
430 if self.follow_mode {
431 self.scroll_to_bottom();
432 }
433 }
434 }
435
436 #[must_use = "use the returned item (if any)"]
438 pub fn get(&self, idx: usize) -> Option<&T> {
439 if let VirtualizedStorage::Owned(items) = &self.storage {
440 items.get(idx)
441 } else {
442 None
443 }
444 }
445
446 #[must_use = "use the returned item (if any)"]
448 pub fn get_mut(&mut self, idx: usize) -> Option<&mut T> {
449 if let VirtualizedStorage::Owned(items) = &mut self.storage {
450 items.get_mut(idx)
451 } else {
452 None
453 }
454 }
455
456 pub fn clear(&mut self) {
458 if let VirtualizedStorage::Owned(items) = &mut self.storage {
459 items.clear();
460 }
461 self.scroll_offset = 0;
462 }
463
464 pub fn trim_front(&mut self, max: usize) -> usize {
468 if let VirtualizedStorage::Owned(items) = &mut self.storage
469 && items.len() > max
470 {
471 let to_remove = items.len() - max;
472 items.drain(..to_remove);
473 self.scroll_offset = self.scroll_offset.saturating_sub(to_remove);
475 return to_remove;
476 }
477 0
478 }
479
480 pub fn iter(&self) -> Box<dyn Iterator<Item = &T> + '_> {
483 match &self.storage {
484 VirtualizedStorage::Owned(items) => Box::new(items.iter()),
485 VirtualizedStorage::External { .. } => Box::new(std::iter::empty()),
486 }
487 }
488
489 pub fn set_external_len(&mut self, len: usize) {
491 if let VirtualizedStorage::External { len: l, .. } = &mut self.storage {
492 *l = len;
493 if self.follow_mode {
494 self.scroll_to_bottom();
495 }
496 }
497 }
498}
499
500impl Default for HeightCache {
501 fn default() -> Self {
502 Self::new(1, 1000)
503 }
504}
505
506impl HeightCache {
507 #[must_use]
509 pub fn new(default_height: u16, capacity: usize) -> Self {
510 Self {
511 cache: Vec::new(),
512 base_offset: 0,
513 default_height,
514 capacity,
515 }
516 }
517
518 #[must_use]
520 pub fn get(&self, idx: usize) -> u16 {
521 if idx < self.base_offset {
522 return self.default_height;
523 }
524 let local = idx - self.base_offset;
525 self.cache
526 .get(local)
527 .and_then(|h| *h)
528 .unwrap_or(self.default_height)
529 }
530
531 pub fn set(&mut self, idx: usize, height: u16) {
533 if self.capacity == 0 {
534 return;
535 }
536 if idx < self.base_offset {
537 return;
539 }
540 let mut local = idx - self.base_offset;
541
542 if local + 1 >= self.cache.len() + self.capacity {
545 self.base_offset = idx.saturating_add(1).saturating_sub(self.capacity);
546 self.cache.clear();
547 local = idx - self.base_offset;
548 }
549
550 if local >= self.cache.len() {
551 self.cache.resize(local + 1, None);
552 }
553 self.cache[local] = Some(height);
554
555 if self.cache.len() > self.capacity {
557 let to_remove = self.cache.len() - self.capacity;
558 self.cache.drain(0..to_remove);
559 self.base_offset += to_remove;
560 }
561 }
562
563 pub fn clear(&mut self) {
565 self.cache.clear();
566 self.base_offset = 0;
567 }
568}
569
570use crate::fenwick::FenwickTree;
575
576#[derive(Debug, Clone)]
596pub struct VariableHeightsFenwick {
597 tree: FenwickTree,
599 default_height: u16,
601 len: usize,
603}
604
605impl Default for VariableHeightsFenwick {
606 fn default() -> Self {
607 Self::new(1, 0)
608 }
609}
610
611impl VariableHeightsFenwick {
612 #[must_use]
614 pub fn new(default_height: u16, capacity: usize) -> Self {
615 let tree = if capacity > 0 {
616 let heights: Vec<u32> = vec![u32::from(default_height); capacity];
618 FenwickTree::from_values(&heights)
619 } else {
620 FenwickTree::new(0)
621 };
622 Self {
623 tree,
624 default_height,
625 len: capacity,
626 }
627 }
628
629 #[must_use]
631 pub fn from_heights(heights: &[u16], default_height: u16) -> Self {
632 let heights_u32: Vec<u32> = heights.iter().map(|&h| u32::from(h)).collect();
633 Self {
634 tree: FenwickTree::from_values(&heights_u32),
635 default_height,
636 len: heights.len(),
637 }
638 }
639
640 #[must_use]
642 pub fn len(&self) -> usize {
643 self.len
644 }
645
646 #[must_use]
648 pub fn is_empty(&self) -> bool {
649 self.len == 0
650 }
651
652 #[must_use]
654 pub fn default_height(&self) -> u16 {
655 self.default_height
656 }
657
658 #[must_use]
660 pub fn get(&self, idx: usize) -> u16 {
661 if idx >= self.len {
662 return self.default_height;
663 }
664 self.tree.get(idx).min(u32::from(u16::MAX)) as u16
666 }
667
668 pub fn set(&mut self, idx: usize, height: u16) {
670 if idx >= self.len {
671 self.resize(idx + 1);
673 }
674 self.tree.set(idx, u32::from(height));
675 }
676
677 #[must_use]
681 pub fn offset_of_item(&self, idx: usize) -> u32 {
682 if idx == 0 || self.len == 0 {
683 return 0;
684 }
685 let clamped = idx.min(self.len);
686 if clamped > 0 {
687 self.tree.prefix(clamped - 1)
688 } else {
689 0
690 }
691 }
692
693 #[must_use]
700 pub fn find_item_at_offset(&self, offset: u32) -> usize {
701 if self.len == 0 {
702 return 0;
703 }
704 if offset == 0 {
705 return 0;
706 }
707 match self.tree.find_prefix(offset) {
715 Some(i) => {
716 (i + 1).min(self.len)
720 }
721 None => {
722 0
724 }
725 }
726 }
727
728 #[must_use]
736 pub fn visible_count(&self, start_idx: usize, viewport_height: u16) -> usize {
737 if self.len == 0 || viewport_height == 0 {
738 return 0;
739 }
740 let start = start_idx.min(self.len);
741 let start_offset = self.offset_of_item(start);
742 let end_offset = start_offset.saturating_add(u32::from(viewport_height));
743
744 let end_idx = self.find_item_at_offset(end_offset);
746
747 if end_idx > start {
749 if end_idx >= self.len {
752 return self.len.saturating_sub(start);
753 }
754 let end_item_start = self.offset_of_item(end_idx);
756 if end_offset > end_item_start {
757 end_idx - start + 1
758 } else {
759 end_idx - start
760 }
761 } else {
762 if viewport_height > 0 && start < self.len {
764 1
765 } else {
766 0
767 }
768 }
769 }
770
771 #[must_use]
773 pub fn total_height(&self) -> u32 {
774 self.tree.total()
775 }
776
777 pub fn resize(&mut self, new_len: usize) {
781 if new_len == self.len {
782 return;
783 }
784 self.tree.resize(new_len);
785 if new_len > self.len {
787 for i in self.len..new_len {
788 self.tree.set(i, u32::from(self.default_height));
789 }
790 }
791 self.len = new_len;
792 }
793
794 pub fn clear(&mut self) {
796 self.tree = FenwickTree::new(0);
797 self.len = 0;
798 }
799
800 pub fn rebuild(&mut self, heights: &[u16]) {
802 let heights_u32: Vec<u32> = heights.iter().map(|&h| u32::from(h)).collect();
803 self.tree = FenwickTree::from_values(&heights_u32);
804 self.len = heights.len();
805 }
806}
807
808pub trait RenderItem {
816 fn render(&self, area: Rect, frame: &mut Frame, selected: bool, skip_rows: u16);
826
827 fn height(&self) -> u16 {
829 1
830 }
831}
832
833#[derive(Debug, Clone)]
835pub struct VirtualizedListState {
836 pub selected: Option<usize>,
838 scroll_offset: usize,
840 visible_count: usize,
842 overscan: usize,
844 follow_mode: bool,
846 scroll_velocity: f32,
848 scrollbar_drag_anchor: Option<usize>,
850 persistence_id: Option<String>,
852}
853
854impl Default for VirtualizedListState {
855 fn default() -> Self {
856 Self::new()
857 }
858}
859
860impl VirtualizedListState {
861 #[must_use]
863 pub fn new() -> Self {
864 Self {
865 selected: None,
866 scroll_offset: 0,
867 visible_count: 0,
868 overscan: 2,
869 follow_mode: false,
870 scroll_velocity: 0.0,
871 scrollbar_drag_anchor: None,
872 persistence_id: None,
873 }
874 }
875
876 #[must_use]
878 pub fn with_overscan(mut self, overscan: usize) -> Self {
879 self.overscan = overscan;
880 self
881 }
882
883 #[must_use]
885 pub fn with_follow(mut self, follow: bool) -> Self {
886 self.follow_mode = follow;
887 self
888 }
889
890 #[must_use]
892 pub fn with_persistence_id(mut self, id: impl Into<String>) -> Self {
893 self.persistence_id = Some(id.into());
894 self
895 }
896
897 #[must_use = "use the persistence id (if any)"]
899 pub fn persistence_id(&self) -> Option<&str> {
900 self.persistence_id.as_deref()
901 }
902
903 #[must_use]
910 pub fn scroll_offset(&self) -> usize {
911 self.scroll_offset
912 }
913
914 #[must_use]
919 pub fn scroll_offset_clamped(&self, total_items: usize) -> usize {
920 if total_items == 0 {
921 return 0;
922 }
923 self.scroll_offset.min(total_items.saturating_sub(1))
924 }
925
926 #[must_use]
928 pub fn visible_count(&self) -> usize {
929 self.visible_count
930 }
931
932 pub fn scroll(&mut self, delta: i32, total_items: usize) {
934 if total_items == 0 {
935 return;
936 }
937 let max_offset = if self.visible_count > 0 {
938 total_items.saturating_sub(self.visible_count)
939 } else {
940 total_items.saturating_sub(1)
941 };
942 let clamped_current = self.scroll_offset.min(max_offset);
944 let new_offset = clamped_current
945 .saturating_add_signed(delta as isize)
946 .min(max_offset);
947 self.scroll_offset = new_offset;
948
949 if delta != 0 {
950 self.follow_mode = false;
951 }
952 }
953
954 pub fn scroll_to(&mut self, idx: usize, total_items: usize) {
956 self.scroll_offset = idx.min(total_items.saturating_sub(1));
957 self.follow_mode = false;
958 }
959
960 pub fn scroll_to_top(&mut self) {
962 self.scroll_offset = 0;
963 self.follow_mode = false;
964 }
965
966 pub fn scroll_to_bottom(&mut self, total_items: usize) {
968 if total_items == 0 {
969 self.scroll_offset = 0;
970 } else {
971 self.scroll_offset = usize::MAX;
974 }
975 }
976
977 pub fn page_up(&mut self, total_items: usize) {
979 if self.visible_count > 0 {
980 let step = if self.visible_count > 1 {
981 self.visible_count - 1
982 } else {
983 1
984 };
985 let delta = i32::try_from(step).unwrap_or(i32::MAX);
986 self.scroll(-delta, total_items);
987 }
988 }
989
990 pub fn page_down(&mut self, total_items: usize) {
992 if self.visible_count > 0 {
993 let step = if self.visible_count > 1 {
994 self.visible_count - 1
995 } else {
996 1
997 };
998 let delta = i32::try_from(step).unwrap_or(i32::MAX);
999 self.scroll(delta, total_items);
1000 }
1001 }
1002
1003 pub fn select(&mut self, index: Option<usize>) {
1005 self.selected = index;
1006 }
1007
1008 pub fn select_previous(&mut self, total_items: usize) {
1010 if total_items == 0 {
1011 self.selected = None;
1012 return;
1013 }
1014 self.selected = Some(match self.selected {
1015 Some(i) if i > 0 => i - 1,
1016 Some(_) => 0,
1017 None => 0,
1018 });
1019 }
1020
1021 pub fn select_next(&mut self, total_items: usize) {
1023 if total_items == 0 {
1024 self.selected = None;
1025 return;
1026 }
1027 self.selected = Some(match self.selected {
1028 Some(i) if i < total_items - 1 => i + 1,
1029 Some(i) => i,
1030 None => 0,
1031 });
1032 }
1033
1034 #[must_use]
1036 pub fn is_at_bottom(&self, total_items: usize) -> bool {
1037 if total_items <= self.visible_count {
1038 true
1039 } else {
1040 self.scroll_offset >= total_items - self.visible_count
1041 }
1042 }
1043
1044 pub fn set_follow(&mut self, follow: bool, total_items: usize) {
1046 self.follow_mode = follow;
1047 if follow {
1048 self.scroll_to_bottom(total_items);
1049 }
1050 }
1051
1052 #[must_use]
1054 pub fn follow_mode(&self) -> bool {
1055 self.follow_mode
1056 }
1057
1058 pub fn fling(&mut self, velocity: f32) {
1060 self.scroll_velocity = velocity;
1061 }
1062
1063 pub fn tick(&mut self, dt: Duration, total_items: usize) {
1065 if self.scroll_velocity.abs() > 0.1 {
1066 let delta = (self.scroll_velocity * dt.as_secs_f32()) as i32;
1067 if delta != 0 {
1068 self.scroll(delta, total_items);
1069 }
1070 self.scroll_velocity *= 0.95;
1071 } else {
1072 self.scroll_velocity = 0.0;
1073 }
1074 }
1075
1076 pub fn handle_mouse(
1081 &mut self,
1082 event: &ftui_core::event::MouseEvent,
1083 hit: Option<(
1084 ftui_render::frame::HitId,
1085 ftui_render::frame::HitRegion,
1086 u64,
1087 )>,
1088 scrollbar_hit_id: ftui_render::frame::HitId,
1089 total_items: usize,
1090 viewport_height: u16,
1091 fixed_item_height: u16,
1092 ) -> crate::mouse::MouseResult {
1093 let items_per_viewport = viewport_height.div_ceil(fixed_item_height.max(1)) as usize;
1095 let mut scrollbar_state =
1096 ScrollbarState::new(total_items, self.scroll_offset, items_per_viewport);
1097
1098 scrollbar_state.drag_anchor = self.scrollbar_drag_anchor;
1100
1101 let result = scrollbar_state.handle_mouse(event, hit, scrollbar_hit_id);
1102
1103 self.scroll_offset = scrollbar_state.position;
1105 self.scrollbar_drag_anchor = scrollbar_state.drag_anchor;
1106
1107 if result == crate::mouse::MouseResult::Scrolled {
1108 self.follow_mode = false;
1109 }
1110
1111 result
1112 }
1113}
1114
1115#[derive(Clone, Debug, Default, PartialEq)]
1124#[cfg_attr(
1125 feature = "state-persistence",
1126 derive(serde::Serialize, serde::Deserialize)
1127)]
1128pub struct VirtualizedListPersistState {
1129 pub selected: Option<usize>,
1131 pub scroll_offset: usize,
1133 pub follow_mode: bool,
1135}
1136
1137impl crate::stateful::Stateful for VirtualizedListState {
1138 type State = VirtualizedListPersistState;
1139
1140 fn state_key(&self) -> crate::stateful::StateKey {
1141 crate::stateful::StateKey::new(
1142 "VirtualizedList",
1143 self.persistence_id.as_deref().unwrap_or("default"),
1144 )
1145 }
1146
1147 fn save_state(&self) -> VirtualizedListPersistState {
1148 VirtualizedListPersistState {
1149 selected: self.selected,
1150 scroll_offset: self.scroll_offset,
1151 follow_mode: self.follow_mode,
1152 }
1153 }
1154
1155 fn restore_state(&mut self, state: VirtualizedListPersistState) {
1156 self.selected = state.selected;
1157 self.scroll_offset = state.scroll_offset;
1158 self.follow_mode = state.follow_mode;
1159 self.scroll_velocity = 0.0;
1161 self.scrollbar_drag_anchor = None;
1162 }
1163}
1164
1165#[derive(Debug)]
1176pub struct VirtualizedList<'a, T> {
1177 items: &'a [T],
1179 style: Style,
1181 highlight_style: Style,
1183 show_scrollbar: bool,
1185 fixed_height: u16,
1187 hit_id: Option<ftui_render::frame::HitId>,
1189}
1190
1191impl<'a, T> VirtualizedList<'a, T> {
1192 #[must_use]
1194 pub fn new(items: &'a [T]) -> Self {
1195 Self {
1196 items,
1197 style: Style::default(),
1198 highlight_style: Style::default(),
1199 show_scrollbar: true,
1200 fixed_height: 1,
1201 hit_id: None,
1202 }
1203 }
1204
1205 #[must_use]
1207 pub fn style(mut self, style: Style) -> Self {
1208 self.style = style;
1209 self
1210 }
1211
1212 #[must_use]
1214 pub fn highlight_style(mut self, style: Style) -> Self {
1215 self.highlight_style = style;
1216 self
1217 }
1218
1219 #[must_use]
1221 pub fn show_scrollbar(mut self, show: bool) -> Self {
1222 self.show_scrollbar = show;
1223 self
1224 }
1225
1226 #[must_use]
1228 pub fn fixed_height(mut self, height: u16) -> Self {
1229 self.fixed_height = height;
1230 self
1231 }
1232
1233 #[must_use]
1235 pub fn hit_id(mut self, id: ftui_render::frame::HitId) -> Self {
1236 self.hit_id = Some(id);
1237 self
1238 }
1239}
1240
1241impl<T: RenderItem> StatefulWidget for VirtualizedList<'_, T> {
1242 type State = VirtualizedListState;
1243
1244 fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
1245 #[cfg(feature = "tracing")]
1246 let _span = tracing::debug_span!(
1247 "widget_render",
1248 widget = "VirtualizedList",
1249 x = area.x,
1250 y = area.y,
1251 w = area.width,
1252 h = area.height,
1253 items = self.items.len()
1254 )
1255 .entered();
1256
1257 if area.is_empty() {
1258 return;
1259 }
1260
1261 clear_text_area(frame, area, self.style);
1264
1265 let total_items = self.items.len();
1266 if total_items == 0 {
1267 return;
1268 }
1269
1270 let fixed_h = self.fixed_height.max(1);
1272 let items_per_viewport = area.height.div_ceil(fixed_h) as usize;
1274 let fully_visible_items = (area.height / fixed_h) as usize;
1275 let needs_scrollbar = self.show_scrollbar && total_items > fully_visible_items;
1276 let content_width = if needs_scrollbar {
1277 area.width.saturating_sub(1)
1278 } else {
1279 area.width
1280 };
1281
1282 if let Some(selected) = state.selected
1284 && selected >= total_items
1285 {
1286 state.selected = if total_items > 0 {
1288 Some(total_items - 1)
1289 } else {
1290 None
1291 };
1292 }
1293
1294 if let Some(selected) = state.selected {
1296 let vis_count = fully_visible_items.max(1);
1297 if selected >= state.scroll_offset.saturating_add(vis_count) {
1298 state.scroll_offset = selected.saturating_sub(vis_count.saturating_sub(1));
1299 } else if selected < state.scroll_offset {
1300 state.scroll_offset = selected;
1301 }
1302 }
1303
1304 let max_offset = if fully_visible_items > 0 {
1306 total_items.saturating_sub(fully_visible_items)
1307 } else {
1308 total_items.saturating_sub(1)
1309 };
1310 state.scroll_offset = state.scroll_offset.min(max_offset);
1311
1312 state.visible_count = fully_visible_items.max(1).min(total_items);
1316
1317 let render_start = state.scroll_offset.saturating_sub(state.overscan);
1319 let render_end = state
1320 .scroll_offset
1321 .saturating_add(items_per_viewport)
1322 .saturating_add(state.overscan)
1323 .min(total_items);
1324
1325 for idx in render_start..render_end {
1327 let relative_idx = if idx >= state.scroll_offset {
1330 i32::try_from(idx - state.scroll_offset).unwrap_or(i32::MAX)
1331 } else {
1332 -(i32::try_from(state.scroll_offset - idx).unwrap_or(i32::MAX))
1334 };
1335
1336 let height_i32 = i32::from(self.fixed_height);
1337 let y_offset = relative_idx.saturating_mul(height_i32);
1338
1339 if y_offset.saturating_add(height_i32) <= 0 {
1346 continue;
1347 }
1348
1349 if y_offset >= i32::from(area.height) {
1351 break;
1352 }
1353
1354 let skip_rows = if y_offset < 0 {
1357 y_offset.unsigned_abs() as u16
1358 } else {
1359 0
1360 };
1361
1362 let y = i32::from(area.y)
1365 .saturating_add(y_offset)
1366 .clamp(i32::from(area.y), i32::from(u16::MAX)) as u16;
1367
1368 if y >= area.bottom() {
1369 break;
1370 }
1371
1372 let visible_height = self
1373 .fixed_height
1374 .saturating_sub(skip_rows)
1375 .min(area.bottom().saturating_sub(y));
1376
1377 if visible_height == 0 {
1378 continue;
1379 }
1380
1381 let row_area = Rect::new(area.x, y, content_width, visible_height);
1382
1383 let is_selected = state.selected == Some(idx);
1384
1385 let row_style = if is_selected {
1386 self.highlight_style.merge(&self.style)
1387 } else {
1388 self.style
1389 };
1390 clear_text_area(frame, row_area, row_style);
1391
1392 self.items[idx].render(row_area, frame, is_selected, skip_rows);
1394 }
1395
1396 if needs_scrollbar {
1398 let scrollbar_area = Rect::new(area.right().saturating_sub(1), area.y, 1, area.height);
1399
1400 let mut scrollbar_state =
1401 ScrollbarState::new(total_items, state.scroll_offset, items_per_viewport);
1402
1403 scrollbar_state.drag_anchor = state.scrollbar_drag_anchor;
1405
1406 let mut scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight);
1407 if let Some(id) = self.hit_id {
1408 scrollbar = scrollbar.hit_id(id);
1409 }
1410 scrollbar.render(scrollbar_area, frame, &mut scrollbar_state);
1411 }
1412 }
1413}
1414
1415impl RenderItem for String {
1420 fn render(&self, area: Rect, frame: &mut Frame, _selected: bool, skip_rows: u16) {
1421 if area.is_empty() {
1422 return;
1423 }
1424 let max_chars = area.width as usize;
1425 if skip_rows > 0 {
1430 return;
1431 }
1432 for (i, ch) in self.chars().take(max_chars).enumerate() {
1433 frame
1434 .buffer
1435 .set(area.x.saturating_add(i as u16), area.y, Cell::from_char(ch));
1436 }
1437 }
1438}
1439
1440impl RenderItem for &str {
1441 fn render(&self, area: Rect, frame: &mut Frame, _selected: bool, skip_rows: u16) {
1442 if area.is_empty() {
1443 return;
1444 }
1445 if skip_rows > 0 {
1446 return;
1447 }
1448 let max_chars = area.width as usize;
1449 for (i, ch) in self.chars().take(max_chars).enumerate() {
1450 frame
1451 .buffer
1452 .set(area.x.saturating_add(i as u16), area.y, Cell::from_char(ch));
1453 }
1454 }
1455}
1456
1457#[cfg(test)]
1458mod tests {
1459 use super::*;
1460 use proptest::prelude::*;
1461
1462 fn raw_row_text(frame: &Frame, y: u16) -> String {
1463 let width = frame.buffer.width();
1464 let mut actual = String::new();
1465 for x in 0..width {
1466 let ch = frame
1467 .buffer
1468 .get(x, y)
1469 .and_then(|cell| cell.content.as_char())
1470 .unwrap_or(' ');
1471 actual.push(ch);
1472 }
1473 actual
1474 }
1475
1476 #[test]
1477 fn test_new_virtualized() {
1478 let virt: Virtualized<String> = Virtualized::new(100);
1479 assert_eq!(virt.len(), 0);
1480 assert!(virt.is_empty());
1481 }
1482
1483 #[test]
1484 fn test_push_and_len() {
1485 let mut virt: Virtualized<i32> = Virtualized::new(100);
1486 virt.push(1);
1487 virt.push(2);
1488 virt.push(3);
1489 assert_eq!(virt.len(), 3);
1490 assert!(!virt.is_empty());
1491 }
1492
1493 #[test]
1494 fn test_visible_range_fixed_height() {
1495 let mut virt: Virtualized<i32> = Virtualized::new(100).with_fixed_height(2);
1496 for i in 0..20 {
1497 virt.push(i);
1498 }
1499 let range = virt.visible_range(20);
1501 assert_eq!(range, 0..10);
1502 }
1503
1504 #[test]
1505 fn test_visible_range_variable_height_clamps() {
1506 let mut cache = HeightCache::new(1, 16);
1507 cache.set(0, 3);
1508 cache.set(1, 3);
1509 cache.set(2, 3);
1510 let mut virt: Virtualized<i32> =
1511 Virtualized::new(10).with_item_height(ItemHeight::Variable(cache));
1512 for i in 0..3 {
1513 virt.push(i);
1514 }
1515 let range = virt.visible_range(5);
1516 assert_eq!(range, 0..2);
1518 }
1519
1520 #[test]
1521 fn test_visible_range_variable_height_exact_fit() {
1522 let mut cache = HeightCache::new(1, 16);
1523 cache.set(0, 2);
1524 cache.set(1, 3);
1525 let mut virt: Virtualized<i32> =
1526 Virtualized::new(10).with_item_height(ItemHeight::Variable(cache));
1527 for i in 0..2 {
1528 virt.push(i);
1529 }
1530 let range = virt.visible_range(5);
1531 assert_eq!(range, 0..2);
1532 }
1533
1534 #[test]
1535 fn test_visible_range_with_scroll() {
1536 let mut virt: Virtualized<i32> = Virtualized::new(100).with_fixed_height(1);
1537 for i in 0..50 {
1538 virt.push(i);
1539 }
1540 virt.scroll(10);
1541 let range = virt.visible_range(10);
1542 assert_eq!(range, 10..20);
1543 }
1544
1545 #[test]
1546 fn test_visible_range_variable_height_excludes_partial() {
1547 let mut cache = HeightCache::new(1, 16);
1548 cache.set(0, 6);
1549 cache.set(1, 6);
1550 let mut virt: Virtualized<i32> =
1551 Virtualized::new(100).with_item_height(ItemHeight::Variable(cache));
1552 virt.push(1);
1553 virt.push(2);
1554 virt.push(3);
1555
1556 let range = virt.visible_range(10);
1557 assert_eq!(range, 0..2);
1559 }
1560
1561 #[test]
1562 fn test_visible_range_variable_height_exact_fit_larger() {
1563 let mut cache = HeightCache::new(1, 16);
1564 cache.set(0, 4);
1565 cache.set(1, 6);
1566 let mut virt: Virtualized<i32> =
1567 Virtualized::new(100).with_item_height(ItemHeight::Variable(cache));
1568 virt.push(1);
1569 virt.push(2);
1570 virt.push(3);
1571
1572 let range = virt.visible_range(10);
1573 assert_eq!(range, 0..2);
1574 }
1575
1576 #[test]
1577 fn test_visible_range_variable_height_default_for_unmeasured() {
1578 let cache = HeightCache::new(2, 16);
1579 let mut virt: Virtualized<i32> =
1580 Virtualized::new(10).with_item_height(ItemHeight::Variable(cache));
1581 for i in 0..3 {
1582 virt.push(i);
1583 }
1584
1585 let range = virt.visible_range(5);
1587 assert_eq!(range, 0..3);
1589 }
1590
1591 #[test]
1592 fn test_render_range_with_overscan() {
1593 let mut virt: Virtualized<i32> =
1594 Virtualized::new(100).with_fixed_height(1).with_overscan(2);
1595 for i in 0..50 {
1596 virt.push(i);
1597 }
1598 virt.scroll(10);
1599 let range = virt.render_range(10);
1600 assert_eq!(range, 8..22);
1603 }
1604
1605 #[test]
1606 fn test_scroll_bounds() {
1607 let mut virt: Virtualized<i32> = Virtualized::new(100);
1608 for i in 0..10 {
1609 virt.push(i);
1610 }
1611
1612 virt.scroll(-100);
1614 assert_eq!(virt.scroll_offset(), 0);
1615
1616 virt.scroll(100);
1618 assert_eq!(virt.scroll_offset(), 9);
1619 }
1620
1621 #[test]
1622 fn test_scroll_to() {
1623 let mut virt: Virtualized<i32> = Virtualized::new(100);
1624 for i in 0..20 {
1625 virt.push(i);
1626 }
1627
1628 virt.scroll_to(15);
1629 assert_eq!(virt.scroll_offset(), 15);
1630
1631 virt.scroll_to(100);
1633 assert_eq!(virt.scroll_offset(), 19);
1634 }
1635
1636 #[test]
1637 fn test_follow_mode() {
1638 let mut virt: Virtualized<i32> = Virtualized::new(100).with_follow(true);
1639 virt.set_visible_count(5);
1640
1641 for i in 0..10 {
1642 virt.push(i);
1643 }
1644
1645 assert!(virt.is_at_bottom());
1647
1648 virt.scroll(-5);
1650 assert!(!virt.follow_mode());
1651 }
1652
1653 #[test]
1654 fn test_scroll_to_start_and_end() {
1655 let mut virt: Virtualized<i32> = Virtualized::new(100);
1656 virt.set_visible_count(5);
1657 for i in 0..20 {
1658 virt.push(i);
1659 }
1660
1661 virt.scroll_to(10);
1663 virt.set_follow(true);
1664 virt.scroll_to_start();
1665 assert_eq!(virt.scroll_offset(), 0);
1666 assert!(!virt.follow_mode());
1667
1668 virt.scroll_to_end();
1670 assert!(virt.is_at_bottom());
1671 assert!(virt.follow_mode());
1672 }
1673
1674 #[test]
1675 fn test_virtualized_page_navigation() {
1676 let mut virt: Virtualized<i32> = Virtualized::new(100);
1677 virt.set_visible_count(5);
1678 for i in 0..30 {
1679 virt.push(i);
1680 }
1681
1682 virt.scroll_to(15);
1683 virt.page_up();
1684 assert_eq!(virt.scroll_offset(), 11);
1686
1687 virt.page_down();
1688 assert_eq!(virt.scroll_offset(), 15);
1689
1690 virt.scroll_to(2);
1692 virt.page_up();
1693 assert_eq!(virt.scroll_offset(), 0);
1694 }
1695
1696 #[test]
1697 fn test_height_cache() {
1698 let mut cache = HeightCache::new(1, 100);
1699
1700 assert_eq!(cache.get(0), 1);
1702 assert_eq!(cache.get(50), 1);
1703
1704 cache.set(5, 3);
1706 assert_eq!(cache.get(5), 3);
1707
1708 assert_eq!(cache.get(4), 1);
1710 assert_eq!(cache.get(6), 1);
1711 }
1712
1713 #[test]
1714 fn test_height_cache_large_index_window() {
1715 let mut cache = HeightCache::new(1, 8);
1716 cache.set(10_000, 4);
1717 assert_eq!(cache.get(10_000), 4);
1718 assert_eq!(cache.get(0), 1);
1719 assert!(cache.cache.len() <= cache.capacity);
1720 }
1721
1722 #[test]
1723 fn test_clear() {
1724 let mut virt: Virtualized<i32> = Virtualized::new(100);
1725 for i in 0..10 {
1726 virt.push(i);
1727 }
1728 virt.scroll(5);
1729
1730 virt.clear();
1731 assert_eq!(virt.len(), 0);
1732 assert_eq!(virt.scroll_offset(), 0);
1733 }
1734
1735 #[test]
1736 fn test_get_item() {
1737 let mut virt: Virtualized<String> = Virtualized::new(100);
1738 virt.push("hello".to_string());
1739 virt.push("world".to_string());
1740
1741 assert_eq!(virt.get(0), Some(&"hello".to_string()));
1742 assert_eq!(virt.get(1), Some(&"world".to_string()));
1743 assert_eq!(virt.get(2), None);
1744 }
1745
1746 #[test]
1747 fn test_external_storage_len() {
1748 let mut virt: Virtualized<i32> = Virtualized::external(1000, 100);
1749 assert_eq!(virt.len(), 1000);
1750
1751 virt.set_external_len(2000);
1752 assert_eq!(virt.len(), 2000);
1753 }
1754
1755 #[test]
1756 fn test_momentum_scrolling() {
1757 let mut virt: Virtualized<i32> = Virtualized::new(100);
1758 for i in 0..50 {
1759 virt.push(i);
1760 }
1761
1762 virt.fling(10.0);
1763
1764 virt.tick(Duration::from_millis(100));
1766
1767 assert!(virt.scroll_offset() > 0);
1769 }
1770
1771 #[test]
1776 fn test_virtualized_list_state_new() {
1777 let state = VirtualizedListState::new();
1778 assert_eq!(state.selected, None);
1779 assert_eq!(state.scroll_offset(), 0);
1780 assert_eq!(state.visible_count(), 0);
1781 }
1782
1783 #[test]
1784 fn test_virtualized_list_state_select_next() {
1785 let mut state = VirtualizedListState::new();
1786
1787 state.select_next(10);
1788 assert_eq!(state.selected, Some(0));
1789
1790 state.select_next(10);
1791 assert_eq!(state.selected, Some(1));
1792
1793 state.selected = Some(9);
1795 state.select_next(10);
1796 assert_eq!(state.selected, Some(9));
1797 }
1798
1799 #[test]
1800 fn test_virtualized_list_state_select_previous() {
1801 let mut state = VirtualizedListState::new();
1802 state.selected = Some(5);
1803
1804 state.select_previous(10);
1805 assert_eq!(state.selected, Some(4));
1806
1807 state.selected = Some(0);
1808 state.select_previous(10);
1809 assert_eq!(state.selected, Some(0));
1810 }
1811
1812 #[test]
1813 fn test_virtualized_list_state_scroll() {
1814 let mut state = VirtualizedListState::new();
1815
1816 state.scroll(5, 20);
1817 assert_eq!(state.scroll_offset(), 5);
1818
1819 state.scroll(-3, 20);
1820 assert_eq!(state.scroll_offset(), 2);
1821
1822 state.scroll(-100, 20);
1824 assert_eq!(state.scroll_offset(), 0);
1825
1826 state.scroll(100, 20);
1828 assert_eq!(state.scroll_offset(), 19);
1829 }
1830
1831 #[test]
1832 fn test_virtualized_list_state_follow_mode() {
1833 let mut state = VirtualizedListState::new().with_follow(true);
1834 assert!(state.follow_mode());
1835
1836 state.scroll(5, 20);
1838 assert!(!state.follow_mode());
1839 }
1840
1841 #[test]
1842 fn test_render_item_string() {
1843 let s = String::from("hello");
1845 assert_eq!(s.height(), 1);
1846 }
1847
1848 #[test]
1849 fn test_page_up_down() {
1850 let mut virt: Virtualized<i32> = Virtualized::new(100);
1851 for i in 0..50 {
1852 virt.push(i);
1853 }
1854 virt.set_visible_count(10);
1855
1856 assert_eq!(virt.scroll_offset(), 0);
1858
1859 virt.page_down();
1861 assert_eq!(virt.scroll_offset(), 9);
1862
1863 virt.page_down();
1865 assert_eq!(virt.scroll_offset(), 18);
1866
1867 virt.page_up();
1869 assert_eq!(virt.scroll_offset(), 9);
1870
1871 virt.page_up();
1873 assert_eq!(virt.scroll_offset(), 0);
1874
1875 virt.page_up();
1877 assert_eq!(virt.scroll_offset(), 0);
1878 }
1879
1880 #[test]
1885 fn test_render_scales_with_visible_not_total() {
1886 use ftui_render::grapheme_pool::GraphemePool;
1887 use std::time::Instant;
1888
1889 let small_items: Vec<String> = (0..1_000).map(|i| format!("Line {}", i)).collect();
1891 let small_list = VirtualizedList::new(&small_items);
1892 let mut small_state = VirtualizedListState::new();
1893
1894 let area = Rect::new(0, 0, 80, 24);
1895 let mut pool = GraphemePool::new();
1896 let mut frame = Frame::new(80, 24, &mut pool);
1897
1898 small_list.render(area, &mut frame, &mut small_state);
1900
1901 let start = Instant::now();
1902 for _ in 0..100 {
1903 frame.buffer.clear();
1904 small_list.render(area, &mut frame, &mut small_state);
1905 }
1906 let small_time = start.elapsed();
1907
1908 let large_items: Vec<String> = (0..100_000).map(|i| format!("Line {}", i)).collect();
1910 let large_list = VirtualizedList::new(&large_items);
1911 let mut large_state = VirtualizedListState::new();
1912
1913 large_list.render(area, &mut frame, &mut large_state);
1915
1916 let start = Instant::now();
1917 for _ in 0..100 {
1918 frame.buffer.clear();
1919 large_list.render(area, &mut frame, &mut large_state);
1920 }
1921 let large_time = start.elapsed();
1922
1923 assert!(
1925 large_time < small_time * 3,
1926 "Render does not scale O(visible): 1K={:?}, 100K={:?}",
1927 small_time,
1928 large_time
1929 );
1930 }
1931
1932 #[test]
1933 fn test_scroll_is_constant_time() {
1934 use std::time::Instant;
1935
1936 let mut small: Virtualized<i32> = Virtualized::new(1_000);
1937 for i in 0..1_000 {
1938 small.push(i);
1939 }
1940 small.set_visible_count(24);
1941
1942 let mut large: Virtualized<i32> = Virtualized::new(100_000);
1943 for i in 0..100_000 {
1944 large.push(i);
1945 }
1946 large.set_visible_count(24);
1947
1948 let iterations = 10_000;
1949
1950 let start = Instant::now();
1951 for _ in 0..iterations {
1952 small.scroll(1);
1953 small.scroll(-1);
1954 }
1955 let small_time = start.elapsed();
1956
1957 let start = Instant::now();
1958 for _ in 0..iterations {
1959 large.scroll(1);
1960 large.scroll(-1);
1961 }
1962 let large_time = start.elapsed();
1963
1964 assert!(
1966 large_time < small_time * 3,
1967 "Scroll is not O(1): 1K={:?}, 100K={:?}",
1968 small_time,
1969 large_time
1970 );
1971 }
1972
1973 #[test]
1974 fn render_partially_offscreen_top_skips_item() {
1975 use ftui_render::grapheme_pool::GraphemePool;
1976
1977 struct IndexedItem(usize);
1979 impl RenderItem for IndexedItem {
1980 fn render(&self, area: Rect, frame: &mut Frame, _selected: bool, _skip_rows: u16) {
1981 let ch = char::from_digit(self.0 as u32, 10).unwrap();
1982 for y in area.y..area.bottom() {
1983 frame.buffer.set(area.x, y, Cell::from_char(ch));
1984 }
1985 }
1986 fn height(&self) -> u16 {
1987 2
1988 }
1989 }
1990
1991 let items = vec![
1994 IndexedItem(0),
1995 IndexedItem(1),
1996 IndexedItem(2),
1997 IndexedItem(3),
1998 ];
1999 let list = VirtualizedList::new(&items).fixed_height(2);
2000
2001 let mut state = VirtualizedListState::new().with_overscan(1);
2003 state.scroll_offset = 1; let mut pool = GraphemePool::new();
2006 let mut frame = Frame::new(10, 5, &mut pool);
2007
2008 list.render(Rect::new(0, 0, 10, 5), &mut frame, &mut state);
2010
2011 let cell = frame.buffer.get(0, 0).unwrap();
2019 assert_eq!(cell.content.as_char(), Some('1'));
2020 }
2021
2022 #[test]
2023 fn render_bottom_boundary_clips_partial_item() {
2024 use ftui_render::grapheme_pool::GraphemePool;
2025
2026 struct IndexedItem(u16);
2027 impl RenderItem for IndexedItem {
2028 fn render(&self, area: Rect, frame: &mut Frame, _selected: bool, _skip_rows: u16) {
2029 let ch = char::from_digit(self.0 as u32, 10).unwrap();
2030 for y in area.y..area.bottom() {
2031 frame.buffer.set(area.x, y, Cell::from_char(ch));
2032 }
2033 }
2034 fn height(&self) -> u16 {
2035 2
2036 }
2037 }
2038
2039 let items = vec![IndexedItem(0), IndexedItem(1), IndexedItem(2)];
2040 let list = VirtualizedList::new(&items)
2041 .fixed_height(2)
2042 .show_scrollbar(false);
2043 let mut state = VirtualizedListState::new();
2044
2045 let mut pool = GraphemePool::new();
2046 let mut frame = Frame::new(4, 4, &mut pool);
2047
2048 list.render(Rect::new(0, 0, 4, 3), &mut frame, &mut state);
2050
2051 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('0'));
2052 assert_eq!(frame.buffer.get(0, 1).unwrap().content.as_char(), Some('0'));
2053 assert_eq!(frame.buffer.get(0, 2).unwrap().content.as_char(), Some('1'));
2054 assert_eq!(frame.buffer.get(0, 3).unwrap().content.as_char(), None);
2056 }
2057
2058 #[test]
2059 fn render_after_fling_advances_visible_rows() {
2060 use ftui_render::grapheme_pool::GraphemePool;
2061
2062 struct IndexedItem(u16);
2063 impl RenderItem for IndexedItem {
2064 fn render(&self, area: Rect, frame: &mut Frame, _selected: bool, _skip_rows: u16) {
2065 let ch = char::from_digit(self.0 as u32, 10).unwrap();
2066 for y in area.y..area.bottom() {
2067 frame.buffer.set(area.x, y, Cell::from_char(ch));
2068 }
2069 }
2070 }
2071
2072 let items: Vec<IndexedItem> = (0..10).map(IndexedItem).collect();
2073 let list = VirtualizedList::new(&items)
2074 .fixed_height(1)
2075 .show_scrollbar(false);
2076 let mut state = VirtualizedListState::new();
2077
2078 let mut pool = GraphemePool::new();
2079 let mut frame = Frame::new(4, 3, &mut pool);
2080 let area = Rect::new(0, 0, 4, 3);
2081
2082 list.render(area, &mut frame, &mut state);
2084 assert_eq!(state.scroll_offset(), 0);
2085 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('0'));
2086
2087 state.fling(40.0);
2089 state.tick(Duration::from_millis(100), items.len());
2090 assert_eq!(state.scroll_offset(), 4);
2091
2092 frame.buffer.clear();
2093 list.render(area, &mut frame, &mut state);
2094 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('4'));
2095 }
2096
2097 #[test]
2098 fn render_empty_virtualized_list_clears_stale_viewport() {
2099 use ftui_render::grapheme_pool::GraphemePool;
2100
2101 let items: Vec<String> = Vec::new();
2102 let list = VirtualizedList::new(&items).show_scrollbar(false);
2103 let mut state = VirtualizedListState::new();
2104 let mut pool = GraphemePool::new();
2105 let mut frame = Frame::new(6, 3, &mut pool);
2106 let area = Rect::new(0, 0, 6, 3);
2107 frame.buffer.fill(area, Cell::from_char('X'));
2108
2109 list.render(area, &mut frame, &mut state);
2110
2111 assert_eq!(raw_row_text(&frame, 0), " ");
2112 assert_eq!(raw_row_text(&frame, 1), " ");
2113 assert_eq!(raw_row_text(&frame, 2), " ");
2114 }
2115
2116 #[test]
2117 fn render_shorter_virtualized_row_clears_stale_suffix() {
2118 use ftui_render::grapheme_pool::GraphemePool;
2119
2120 let long_items = vec!["Hello".to_string()];
2121 let short_items = vec!["Hi".to_string()];
2122 let area = Rect::new(0, 0, 6, 1);
2123 let mut state = VirtualizedListState::new();
2124 let mut pool = GraphemePool::new();
2125 let mut frame = Frame::new(6, 1, &mut pool);
2126
2127 VirtualizedList::new(&long_items)
2128 .show_scrollbar(false)
2129 .render(area, &mut frame, &mut state);
2130 VirtualizedList::new(&short_items)
2131 .show_scrollbar(false)
2132 .render(area, &mut frame, &mut state);
2133
2134 assert_eq!(raw_row_text(&frame, 0), "Hi ");
2135 }
2136
2137 #[test]
2138 fn test_memory_bounded_by_ring_capacity() {
2139 use crate::log_ring::LogRing;
2140
2141 let mut ring: LogRing<String> = LogRing::new(1_000);
2142
2143 for i in 0..100_000 {
2145 ring.push(format!("Line {}", i));
2146 }
2147
2148 assert_eq!(ring.len(), 1_000);
2150 assert_eq!(ring.total_count(), 100_000);
2151 assert_eq!(ring.first_index(), 99_000);
2152
2153 assert!(ring.get(99_999).is_some());
2155 assert!(ring.get(99_000).is_some());
2156 assert!(ring.get(0).is_none());
2158 assert!(ring.get(98_999).is_none());
2159 }
2160
2161 #[test]
2162 fn test_visible_range_constant_regardless_of_total() {
2163 let mut small: Virtualized<i32> = Virtualized::new(100);
2164 for i in 0..100 {
2165 small.push(i);
2166 }
2167 let small_range = small.visible_range(24);
2168
2169 let mut large: Virtualized<i32> = Virtualized::new(100_000);
2170 for i in 0..100_000 {
2171 large.push(i);
2172 }
2173 let large_range = large.visible_range(24);
2174
2175 assert_eq!(small_range.end - small_range.start, 24);
2177 assert_eq!(large_range.end - large_range.start, 24);
2178 }
2179
2180 #[test]
2181 fn test_virtualized_list_state_page_up_down() {
2182 let mut state = VirtualizedListState::new();
2183 state.visible_count = 10;
2184
2185 state.page_down(50);
2187 assert_eq!(state.scroll_offset(), 9);
2188
2189 state.page_down(50);
2191 assert_eq!(state.scroll_offset(), 18);
2192
2193 state.page_up(50);
2195 assert_eq!(state.scroll_offset(), 9);
2196
2197 state.page_up(50);
2199 assert_eq!(state.scroll_offset(), 0);
2200 }
2201
2202 #[test]
2207 fn test_variable_heights_fenwick_new() {
2208 let tracker = VariableHeightsFenwick::new(2, 10);
2209 assert_eq!(tracker.len(), 10);
2210 assert!(!tracker.is_empty());
2211 assert_eq!(tracker.default_height(), 2);
2212 }
2213
2214 #[test]
2215 fn test_variable_heights_fenwick_empty() {
2216 let tracker = VariableHeightsFenwick::new(1, 0);
2217 assert!(tracker.is_empty());
2218 assert_eq!(tracker.total_height(), 0);
2219 }
2220
2221 #[test]
2222 fn test_variable_heights_fenwick_from_heights() {
2223 let heights = vec![3, 2, 5, 1, 4];
2224 let tracker = VariableHeightsFenwick::from_heights(&heights, 1);
2225
2226 assert_eq!(tracker.len(), 5);
2227 assert_eq!(tracker.get(0), 3);
2228 assert_eq!(tracker.get(1), 2);
2229 assert_eq!(tracker.get(2), 5);
2230 assert_eq!(tracker.get(3), 1);
2231 assert_eq!(tracker.get(4), 4);
2232 assert_eq!(tracker.total_height(), 15);
2233 }
2234
2235 #[test]
2236 fn test_variable_heights_fenwick_offset_of_item() {
2237 let heights = vec![3, 2, 5, 1, 4];
2239 let tracker = VariableHeightsFenwick::from_heights(&heights, 1);
2240
2241 assert_eq!(tracker.offset_of_item(0), 0);
2242 assert_eq!(tracker.offset_of_item(1), 3);
2243 assert_eq!(tracker.offset_of_item(2), 5);
2244 assert_eq!(tracker.offset_of_item(3), 10);
2245 assert_eq!(tracker.offset_of_item(4), 11);
2246 assert_eq!(tracker.offset_of_item(5), 15); }
2248
2249 #[test]
2250 fn test_variable_heights_fenwick_find_item_at_offset() {
2251 let heights = vec![3, 2, 5, 1, 4];
2253 let tracker = VariableHeightsFenwick::from_heights(&heights, 1);
2254
2255 assert_eq!(tracker.find_item_at_offset(0), 0);
2257 assert_eq!(tracker.find_item_at_offset(1), 0);
2259 assert_eq!(tracker.find_item_at_offset(3), 1);
2261 assert_eq!(tracker.find_item_at_offset(5), 2);
2263 assert_eq!(tracker.find_item_at_offset(10), 3);
2265 assert_eq!(tracker.find_item_at_offset(11), 4);
2267 assert_eq!(tracker.find_item_at_offset(15), 5);
2269 }
2270
2271 #[test]
2272 fn test_variable_heights_fenwick_visible_count() {
2273 let heights = vec![3, 2, 5, 1, 4];
2275 let tracker = VariableHeightsFenwick::from_heights(&heights, 1);
2276
2277 assert_eq!(tracker.visible_count(0, 5), 2);
2279
2280 assert_eq!(tracker.visible_count(0, 4), 2);
2282
2283 assert_eq!(tracker.visible_count(0, 10), 3);
2285
2286 assert_eq!(tracker.visible_count(2, 6), 2);
2288 }
2289
2290 #[test]
2291 fn test_variable_heights_fenwick_visible_count_viewport_beyond_total_height() {
2292 let heights = vec![1, 1, 1];
2293 let tracker = VariableHeightsFenwick::from_heights(&heights, 1);
2294
2295 assert_eq!(tracker.visible_count(0, 10), 3);
2297 assert_eq!(tracker.visible_count(1, 10), 2);
2298 assert_eq!(tracker.visible_count(2, 10), 1);
2299 }
2300
2301 #[test]
2302 fn test_variable_heights_fenwick_set() {
2303 let mut tracker = VariableHeightsFenwick::new(1, 5);
2304
2305 assert_eq!(tracker.get(0), 1);
2307 assert_eq!(tracker.total_height(), 5);
2308
2309 tracker.set(2, 10);
2311 assert_eq!(tracker.get(2), 10);
2312 assert_eq!(tracker.total_height(), 14); }
2314
2315 #[test]
2316 fn test_variable_heights_fenwick_resize() {
2317 let mut tracker = VariableHeightsFenwick::new(2, 3);
2318 assert_eq!(tracker.len(), 3);
2319 assert_eq!(tracker.total_height(), 6);
2320
2321 tracker.resize(5);
2323 assert_eq!(tracker.len(), 5);
2324 assert_eq!(tracker.total_height(), 10);
2325 assert_eq!(tracker.get(4), 2);
2326
2327 tracker.resize(2);
2329 assert_eq!(tracker.len(), 2);
2330 assert_eq!(tracker.total_height(), 4);
2331 }
2332
2333 #[test]
2334 fn test_variable_heights_fenwick_point_update_and_range_query() {
2335 fn range_sum(tracker: &VariableHeightsFenwick, left: usize, right: usize) -> u32 {
2336 tracker
2337 .offset_of_item(right.saturating_add(1))
2338 .saturating_sub(tracker.offset_of_item(left))
2339 }
2340
2341 let mut tracker = VariableHeightsFenwick::from_heights(&[2, 4, 1, 3, 5, 2], 1);
2342 let mut naive = [2_u32, 4, 1, 3, 5, 2];
2343
2344 tracker.set(2, 7);
2345 naive[2] = 7;
2346 tracker.set(5, 1);
2347 naive[5] = 1;
2348 tracker.set(0, 6);
2349 naive[0] = 6;
2350
2351 let mut running = 0u32;
2352 for (i, value) in naive.iter().enumerate() {
2353 running = running.saturating_add(*value);
2354 assert_eq!(tracker.offset_of_item(i + 1), running);
2355 }
2356
2357 let naive_sum = |left: usize, right: usize| -> u32 { naive[left..=right].iter().sum() };
2358 assert_eq!(range_sum(&tracker, 0, 0), naive_sum(0, 0));
2359 assert_eq!(range_sum(&tracker, 1, 3), naive_sum(1, 3));
2360 assert_eq!(range_sum(&tracker, 2, 5), naive_sum(2, 5));
2361 }
2362
2363 proptest! {
2364 #![proptest_config(ProptestConfig::with_cases(96))]
2365
2366 #[test]
2367 fn property_variable_heights_fenwick_prefix_sums_match_naive(
2368 heights in proptest::collection::vec(1u16..=32u16, 1..160)
2369 ) {
2370 let tracker = VariableHeightsFenwick::from_heights(&heights, 1);
2371
2372 let mut naive_prefix = 0u32;
2373 prop_assert_eq!(tracker.offset_of_item(0), 0);
2374 for (i, height) in heights.iter().enumerate() {
2375 naive_prefix = naive_prefix.saturating_add(u32::from(*height));
2376 prop_assert_eq!(
2377 tracker.offset_of_item(i + 1),
2378 naive_prefix,
2379 "prefix mismatch at index {} for heights {:?}",
2380 i,
2381 heights
2382 );
2383 }
2384 }
2385
2386 #[test]
2387 fn property_variable_heights_fenwick_visible_count_matches_naive(
2388 heights in proptest::collection::vec(1u16..=24u16, 1..128),
2389 start_idx in 0usize..192usize,
2390 viewport_height in 1u16..=120u16
2391 ) {
2392 fn naive_visible_count(heights: &[u16], start_idx: usize, viewport_height: u16) -> usize {
2393 if heights.is_empty() || viewport_height == 0 {
2394 return 0;
2395 }
2396 let start = start_idx.min(heights.len());
2397 if start >= heights.len() {
2398 return 0;
2399 }
2400
2401 let start_offset: u32 = heights[..start].iter().map(|&h| u32::from(h)).sum();
2402 let end_offset = start_offset.saturating_add(u32::from(viewport_height));
2403 let mut count = 0usize;
2404 let mut cursor = start_offset;
2405 for &height in &heights[start..] {
2406 if cursor >= end_offset {
2407 break;
2408 }
2409 count = count.saturating_add(1);
2410 cursor = cursor.saturating_add(u32::from(height));
2411 }
2412
2413 if count == 0 { 1 } else { count }
2414 }
2415
2416 let tracker = VariableHeightsFenwick::from_heights(&heights, 1);
2417 let expected = naive_visible_count(&heights, start_idx, viewport_height);
2418 let actual = tracker.visible_count(start_idx, viewport_height);
2419 prop_assert_eq!(
2420 actual,
2421 expected,
2422 "visible_count mismatch for start={} viewport={} heights={:?}",
2423 start_idx,
2424 viewport_height,
2425 heights
2426 );
2427 }
2428 }
2429
2430 #[test]
2431 fn test_virtualized_with_variable_heights_fenwick() {
2432 let mut virt: Virtualized<i32> = Virtualized::new(100).with_variable_heights_fenwick(2, 10);
2433
2434 for i in 0..10 {
2435 virt.push(i);
2436 }
2437
2438 let range = virt.visible_range(6);
2440 assert_eq!(range.end - range.start, 3);
2441 }
2442
2443 #[test]
2444 fn test_variable_heights_fenwick_performance() {
2445 use std::time::Instant;
2446
2447 let n = 100_000;
2449 let heights: Vec<u16> = (0..n).map(|i| (i % 10 + 1) as u16).collect();
2450 let tracker = VariableHeightsFenwick::from_heights(&heights, 1);
2451
2452 let _ = tracker.find_item_at_offset(500_000);
2454 let _ = tracker.offset_of_item(50_000);
2455
2456 let start = Instant::now();
2458 let mut _sink = 0usize;
2459 for i in 0..10_000 {
2460 _sink = _sink.wrapping_add(tracker.find_item_at_offset((i * 50) as u32));
2461 }
2462 let find_time = start.elapsed();
2463
2464 let start = Instant::now();
2466 let mut _sink2 = 0u32;
2467 for i in 0..10_000 {
2468 _sink2 = _sink2.wrapping_add(tracker.offset_of_item((i * 10) % n));
2469 }
2470 let offset_time = start.elapsed();
2471
2472 eprintln!("=== VariableHeightsFenwick Performance (n={n}) ===");
2473 eprintln!("10k find_item_at_offset: {:?}", find_time);
2474 eprintln!("10k offset_of_item: {:?}", offset_time);
2475
2476 assert!(
2478 find_time < std::time::Duration::from_millis(50),
2479 "find_item_at_offset too slow: {:?}",
2480 find_time
2481 );
2482 assert!(
2483 offset_time < std::time::Duration::from_millis(50),
2484 "offset_of_item too slow: {:?}",
2485 offset_time
2486 );
2487 }
2488
2489 #[test]
2490 fn test_variable_heights_fenwick_scales_logarithmically() {
2491 use std::time::Instant;
2492
2493 let small_n = 1_000;
2495 let small_heights: Vec<u16> = (0..small_n).map(|i| (i % 5 + 1) as u16).collect();
2496 let small_tracker = VariableHeightsFenwick::from_heights(&small_heights, 1);
2497
2498 let large_n = 100_000;
2500 let large_heights: Vec<u16> = (0..large_n).map(|i| (i % 5 + 1) as u16).collect();
2501 let large_tracker = VariableHeightsFenwick::from_heights(&large_heights, 1);
2502
2503 let iterations = 5_000;
2504
2505 let start = Instant::now();
2507 for i in 0..iterations {
2508 let _ = small_tracker.find_item_at_offset((i * 2) as u32);
2509 }
2510 let small_time = start.elapsed();
2511
2512 let start = Instant::now();
2514 for i in 0..iterations {
2515 let _ = large_tracker.find_item_at_offset((i * 200) as u32);
2516 }
2517 let large_time = start.elapsed();
2518
2519 assert!(
2522 large_time < small_time * 10,
2523 "Not O(log n): small={:?}, large={:?}",
2524 small_time,
2525 large_time
2526 );
2527 }
2528
2529 #[test]
2536 fn new_zero_capacity() {
2537 let virt: Virtualized<i32> = Virtualized::new(0);
2538 assert_eq!(virt.len(), 0);
2539 assert!(virt.is_empty());
2540 assert_eq!(virt.scroll_offset(), 0);
2541 assert_eq!(virt.visible_count(), 0);
2542 assert!(!virt.follow_mode());
2543 }
2544
2545 #[test]
2546 fn external_zero_len_zero_cache() {
2547 let virt: Virtualized<i32> = Virtualized::external(0, 0);
2548 assert_eq!(virt.len(), 0);
2549 assert!(virt.is_empty());
2550 }
2551
2552 #[test]
2553 fn external_storage_returns_none_for_get() {
2554 let virt: Virtualized<i32> = Virtualized::external(100, 10);
2555 assert_eq!(virt.get(0), None);
2556 assert_eq!(virt.get(50), None);
2557 }
2558
2559 #[test]
2560 fn external_storage_returns_none_for_get_mut() {
2561 let mut virt: Virtualized<i32> = Virtualized::external(100, 10);
2562 assert!(virt.get_mut(0).is_none());
2563 }
2564
2565 #[test]
2566 fn push_on_external_is_noop() {
2567 let mut virt: Virtualized<i32> = Virtualized::external(5, 10);
2568 virt.push(42);
2569 assert_eq!(virt.len(), 5);
2571 }
2572
2573 #[test]
2574 fn iter_on_external_is_empty() {
2575 let virt: Virtualized<i32> = Virtualized::external(100, 10);
2576 assert_eq!(virt.iter().count(), 0);
2577 }
2578
2579 #[test]
2580 fn set_external_len_on_owned_is_noop() {
2581 let mut virt: Virtualized<i32> = Virtualized::new(100);
2582 virt.push(1);
2583 virt.set_external_len(999);
2584 assert_eq!(virt.len(), 1); }
2586
2587 #[test]
2590 fn visible_range_zero_viewport() {
2591 let mut virt: Virtualized<i32> = Virtualized::new(100);
2592 virt.push(1);
2593 let range = virt.visible_range(0);
2594 assert_eq!(range, 0..0);
2595 assert_eq!(virt.visible_count(), 0);
2596 }
2597
2598 #[test]
2599 fn visible_range_empty_container() {
2600 let virt: Virtualized<i32> = Virtualized::new(100);
2601 let range = virt.visible_range(24);
2602 assert_eq!(range, 0..0);
2603 }
2604
2605 #[test]
2606 fn visible_range_fixed_height_zero() {
2607 let mut virt: Virtualized<i32> = Virtualized::new(100).with_fixed_height(0);
2609 for i in 0..10 {
2610 virt.push(i);
2611 }
2612 let range = virt.visible_range(5);
2613 assert_eq!(range, 0..5);
2615 }
2616
2617 #[test]
2618 fn visible_range_fewer_items_than_viewport() {
2619 let mut virt: Virtualized<i32> = Virtualized::new(100);
2620 for i in 0..3 {
2621 virt.push(i);
2622 }
2623 let range = virt.visible_range(24);
2624 assert_eq!(range, 0..3);
2626 }
2627
2628 #[test]
2629 fn visible_range_single_item() {
2630 let mut virt: Virtualized<i32> = Virtualized::new(100);
2631 virt.push(42);
2632 let range = virt.visible_range(1);
2633 assert_eq!(range, 0..1);
2634 }
2635
2636 #[test]
2639 fn render_range_at_start_clamps_overscan() {
2640 let mut virt: Virtualized<i32> =
2641 Virtualized::new(100).with_fixed_height(1).with_overscan(5);
2642 for i in 0..20 {
2643 virt.push(i);
2644 }
2645 let range = virt.render_range(10);
2647 assert_eq!(range.start, 0);
2648 }
2649
2650 #[test]
2651 fn render_range_at_end_clamps_overscan() {
2652 let mut virt: Virtualized<i32> =
2653 Virtualized::new(100).with_fixed_height(1).with_overscan(5);
2654 for i in 0..20 {
2655 virt.push(i);
2656 }
2657 virt.set_visible_count(10);
2658 virt.scroll_to(10); let range = virt.render_range(10);
2660 assert_eq!(range.end, 20);
2662 }
2663
2664 #[test]
2665 fn render_range_zero_overscan() {
2666 let mut virt: Virtualized<i32> =
2667 Virtualized::new(100).with_fixed_height(1).with_overscan(0);
2668 for i in 0..20 {
2669 virt.push(i);
2670 }
2671 virt.set_visible_count(10);
2672 virt.scroll_to(5);
2673 let range = virt.render_range(10);
2674 let visible = virt.visible_range(10);
2676 assert_eq!(range, visible);
2677 }
2678
2679 #[test]
2682 fn scroll_on_empty_is_noop() {
2683 let mut virt: Virtualized<i32> = Virtualized::new(100);
2684 virt.scroll(10);
2685 assert_eq!(virt.scroll_offset(), 0);
2686 }
2687
2688 #[test]
2689 fn scroll_delta_zero_does_not_disable_follow() {
2690 let mut virt: Virtualized<i32> = Virtualized::new(100).with_follow(true);
2691 virt.push(1);
2692 virt.scroll(0);
2693 assert!(virt.follow_mode());
2695 }
2696
2697 #[test]
2698 fn scroll_negative_beyond_start() {
2699 let mut virt: Virtualized<i32> = Virtualized::new(100);
2700 for i in 0..10 {
2701 virt.push(i);
2702 }
2703 virt.scroll(-1);
2704 assert_eq!(virt.scroll_offset(), 0);
2705 }
2706
2707 #[test]
2708 fn scroll_to_on_empty() {
2709 let mut virt: Virtualized<i32> = Virtualized::new(100);
2710 virt.scroll_to(100);
2712 assert_eq!(virt.scroll_offset(), 0);
2713 }
2714
2715 #[test]
2716 fn scroll_to_top_already_at_top() {
2717 let mut virt: Virtualized<i32> = Virtualized::new(100);
2718 virt.push(1);
2719 virt.scroll_to_top();
2720 assert_eq!(virt.scroll_offset(), 0);
2721 }
2722
2723 #[test]
2724 fn scroll_to_bottom_fewer_items_than_visible() {
2725 let mut virt: Virtualized<i32> = Virtualized::new(100);
2726 virt.set_visible_count(10);
2727 for i in 0..3 {
2728 virt.push(i);
2729 }
2730 virt.scroll_to_bottom();
2731 assert_eq!(virt.scroll_offset(), 0);
2733 }
2734
2735 #[test]
2736 fn scroll_to_bottom_visible_count_zero() {
2737 let mut virt: Virtualized<i32> = Virtualized::new(100);
2738 for i in 0..20 {
2739 virt.push(i);
2740 }
2741 virt.scroll_to_bottom();
2743 assert_eq!(virt.scroll_offset, usize::MAX);
2745 assert_eq!(virt.scroll_offset(), 19);
2747 }
2748
2749 #[test]
2752 fn page_up_visible_count_zero_is_noop() {
2753 let mut virt: Virtualized<i32> = Virtualized::new(100);
2754 for i in 0..20 {
2755 virt.push(i);
2756 }
2757 virt.scroll_to(10);
2758 virt.page_up();
2760 assert_eq!(virt.scroll_offset(), 10);
2761 }
2762
2763 #[test]
2764 fn page_down_visible_count_zero_is_noop() {
2765 let mut virt: Virtualized<i32> = Virtualized::new(100);
2766 for i in 0..20 {
2767 virt.push(i);
2768 }
2769 virt.page_down();
2771 assert_eq!(virt.scroll_offset(), 0);
2772 }
2773
2774 #[test]
2777 fn is_at_bottom_fewer_items_than_visible() {
2778 let mut virt: Virtualized<i32> = Virtualized::new(100);
2779 virt.set_visible_count(10);
2780 for i in 0..3 {
2781 virt.push(i);
2782 }
2783 assert!(virt.is_at_bottom());
2784 }
2785
2786 #[test]
2787 fn is_at_bottom_empty() {
2788 let virt: Virtualized<i32> = Virtualized::new(100);
2789 assert!(virt.is_at_bottom());
2791 }
2792
2793 #[test]
2796 fn trim_front_under_max_returns_zero() {
2797 let mut virt: Virtualized<i32> = Virtualized::new(100);
2798 for i in 0..5 {
2799 virt.push(i);
2800 }
2801 let removed = virt.trim_front(10);
2802 assert_eq!(removed, 0);
2803 assert_eq!(virt.len(), 5);
2804 }
2805
2806 #[test]
2807 fn trim_front_adjusts_scroll_offset() {
2808 let mut virt: Virtualized<i32> = Virtualized::new(100);
2809 for i in 0..20 {
2810 virt.push(i);
2811 }
2812 virt.scroll_to(10);
2813 let removed = virt.trim_front(15);
2814 assert_eq!(removed, 5);
2815 assert_eq!(virt.len(), 15);
2816 assert_eq!(virt.scroll_offset(), 5);
2818 }
2819
2820 #[test]
2821 fn trim_front_scroll_offset_saturates_to_zero() {
2822 let mut virt: Virtualized<i32> = Virtualized::new(100);
2823 for i in 0..20 {
2824 virt.push(i);
2825 }
2826 virt.scroll_to(2);
2827 let removed = virt.trim_front(10);
2828 assert_eq!(removed, 10);
2829 assert_eq!(virt.scroll_offset(), 0);
2831 }
2832
2833 #[test]
2834 fn trim_front_on_external_returns_zero() {
2835 let mut virt: Virtualized<i32> = Virtualized::external(100, 10);
2836 let removed = virt.trim_front(5);
2837 assert_eq!(removed, 0);
2838 }
2839
2840 #[test]
2841 fn scroll_to_bottom_sets_sentinel_for_lazy_clamping() {
2842 let mut virt: Virtualized<i32> = Virtualized::new(100);
2843 for i in 0..20 {
2844 virt.push(i);
2845 }
2846
2847 virt.scroll_to_bottom();
2850 assert_eq!(virt.scroll_offset, usize::MAX);
2851
2852 let range = virt.visible_range(10);
2855 assert_eq!(range, 10..20);
2856 assert_eq!(virt.visible_count(), 10);
2857 }
2858
2859 #[test]
2862 fn clear_on_external_resets_scroll() {
2863 let mut virt: Virtualized<i32> = Virtualized::external(100, 10);
2864 virt.scroll_to(50);
2865 virt.clear();
2866 assert_eq!(virt.scroll_offset(), 0);
2867 assert_eq!(virt.len(), 100);
2869 }
2870
2871 #[test]
2874 fn tick_zero_velocity_is_noop() {
2875 let mut virt: Virtualized<i32> = Virtualized::new(100);
2876 for i in 0..20 {
2877 virt.push(i);
2878 }
2879 virt.tick(Duration::from_millis(100));
2880 assert_eq!(virt.scroll_offset(), 0);
2881 }
2882
2883 #[test]
2884 fn tick_below_threshold_stops_momentum() {
2885 let mut virt: Virtualized<i32> = Virtualized::new(100);
2886 for i in 0..20 {
2887 virt.push(i);
2888 }
2889 virt.fling(0.05); virt.tick(Duration::from_millis(100));
2891 assert_eq!(virt.scroll_offset(), 0);
2893 }
2894
2895 #[test]
2896 fn tick_zero_duration_no_scroll() {
2897 let mut virt: Virtualized<i32> = Virtualized::new(100);
2898 for i in 0..50 {
2899 virt.push(i);
2900 }
2901 virt.fling(100.0);
2902 virt.tick(Duration::ZERO);
2903 assert_eq!(virt.scroll_offset(), 0);
2905 }
2906
2907 #[test]
2908 fn fling_negative_scrolls_up() {
2909 let mut virt: Virtualized<i32> = Virtualized::new(100);
2910 for i in 0..50 {
2911 virt.push(i);
2912 }
2913 virt.scroll(20);
2914 let before = virt.scroll_offset();
2915 virt.fling(-50.0);
2916 virt.tick(Duration::from_millis(100));
2917 assert!(virt.scroll_offset() < before);
2918 }
2919
2920 #[test]
2923 fn follow_mode_auto_scrolls_on_push() {
2924 let mut virt: Virtualized<i32> = Virtualized::new(100).with_follow(true);
2925 virt.set_visible_count(5);
2926 for i in 0..20 {
2927 virt.push(i);
2928 }
2929 assert!(virt.is_at_bottom());
2931 assert_eq!(virt.scroll_offset(), 15); }
2933
2934 #[test]
2935 fn set_follow_false_does_not_scroll() {
2936 let mut virt: Virtualized<i32> = Virtualized::new(100);
2937 virt.set_visible_count(5);
2938 for i in 0..20 {
2939 virt.push(i);
2940 }
2941 virt.scroll_to(5);
2942 virt.set_follow(false);
2943 assert_eq!(virt.scroll_offset(), 5); }
2945
2946 #[test]
2947 fn scroll_to_start_disables_follow() {
2948 let mut virt: Virtualized<i32> = Virtualized::new(100).with_follow(true);
2949 virt.set_visible_count(5);
2950 for i in 0..20 {
2951 virt.push(i);
2952 }
2953 virt.scroll_to_start();
2954 assert!(!virt.follow_mode());
2955 assert_eq!(virt.scroll_offset(), 0);
2956 }
2957
2958 #[test]
2959 fn scroll_to_end_enables_follow() {
2960 let mut virt: Virtualized<i32> = Virtualized::new(100);
2961 virt.set_visible_count(5);
2962 for i in 0..20 {
2963 virt.push(i);
2964 }
2965 assert!(!virt.follow_mode());
2966 virt.scroll_to_end();
2967 assert!(virt.follow_mode());
2968 assert!(virt.is_at_bottom());
2969 }
2970
2971 #[test]
2972 fn external_follow_mode_scrolls_on_set_external_len() {
2973 let mut virt: Virtualized<i32> = Virtualized::external(10, 100).with_follow(true);
2974 virt.set_visible_count(5);
2975 virt.set_external_len(20);
2976 assert_eq!(virt.len(), 20);
2977 assert!(virt.is_at_bottom());
2978 }
2979
2980 #[test]
2983 fn builder_chain_all_options() {
2984 let virt: Virtualized<i32> = Virtualized::new(100)
2985 .with_fixed_height(3)
2986 .with_overscan(5)
2987 .with_follow(true);
2988 assert!(virt.follow_mode());
2989 let range = virt.visible_range(9);
2992 assert_eq!(range, 0..0);
2993 }
2994
2995 #[test]
2998 fn height_cache_default() {
2999 let cache = HeightCache::default();
3000 assert_eq!(cache.get(0), 1); assert_eq!(cache.capacity, 1000);
3002 }
3003
3004 #[test]
3005 fn height_cache_get_before_base_offset() {
3006 let mut cache = HeightCache::new(5, 100);
3007 cache.set(200, 10); assert_eq!(cache.get(0), 5);
3011 }
3012
3013 #[test]
3014 fn height_cache_set_before_base_offset_ignored() {
3015 let mut cache = HeightCache::new(5, 100);
3016 cache.set(200, 10);
3017 let base = cache.base_offset;
3018 cache.set(0, 99); assert_eq!(cache.get(0), 5); assert_eq!(cache.base_offset, base); }
3022
3023 #[test]
3024 fn height_cache_capacity_zero_ignores_all_sets() {
3025 let mut cache = HeightCache::new(3, 0);
3026 cache.set(0, 10);
3027 cache.set(5, 20);
3028 assert_eq!(cache.get(0), 3);
3030 assert_eq!(cache.get(5), 3);
3031 }
3032
3033 #[test]
3034 fn height_cache_clear_resets_base() {
3035 let mut cache = HeightCache::new(1, 100);
3036 cache.set(50, 10);
3037 cache.clear();
3038 assert_eq!(cache.base_offset, 0);
3039 assert_eq!(cache.get(50), 1); }
3041
3042 #[test]
3043 fn height_cache_eviction_trims_oldest() {
3044 let mut cache = HeightCache::new(1, 4);
3045 for i in 0..6 {
3047 cache.set(i, (i + 10) as u16);
3048 }
3049 assert!(cache.cache.len() <= cache.capacity);
3051 assert_eq!(cache.get(5), 15);
3053 assert_eq!(cache.get(4), 14);
3054 assert_eq!(cache.get(3), 13);
3055 assert_eq!(cache.get(2), 12);
3056 assert_eq!(cache.get(1), 1);
3058 assert_eq!(cache.get(0), 1);
3059 }
3060
3061 #[test]
3064 fn fenwick_default_is_empty() {
3065 let tracker = VariableHeightsFenwick::default();
3066 assert!(tracker.is_empty());
3067 assert_eq!(tracker.len(), 0);
3068 assert_eq!(tracker.total_height(), 0);
3069 assert_eq!(tracker.default_height(), 1);
3070 }
3071
3072 #[test]
3073 fn fenwick_get_beyond_len_returns_default() {
3074 let tracker = VariableHeightsFenwick::new(3, 5);
3075 assert_eq!(tracker.get(5), 3); assert_eq!(tracker.get(100), 3);
3077 }
3078
3079 #[test]
3080 fn fenwick_set_beyond_len_resizes() {
3081 let mut tracker = VariableHeightsFenwick::new(2, 3);
3082 assert_eq!(tracker.len(), 3);
3083 tracker.set(10, 7);
3084 assert!(tracker.len() > 10);
3085 assert_eq!(tracker.get(10), 7);
3086 }
3087
3088 #[test]
3089 fn fenwick_offset_of_item_zero_always_zero() {
3090 let tracker = VariableHeightsFenwick::new(5, 10);
3091 assert_eq!(tracker.offset_of_item(0), 0);
3092
3093 let empty = VariableHeightsFenwick::new(5, 0);
3094 assert_eq!(empty.offset_of_item(0), 0);
3095 }
3096
3097 #[test]
3098 fn fenwick_find_item_at_offset_empty() {
3099 let tracker = VariableHeightsFenwick::new(1, 0);
3100 assert_eq!(tracker.find_item_at_offset(0), 0);
3101 assert_eq!(tracker.find_item_at_offset(100), 0);
3102 }
3103
3104 #[test]
3105 fn fenwick_visible_count_zero_viewport() {
3106 let tracker = VariableHeightsFenwick::new(2, 10);
3107 assert_eq!(tracker.visible_count(0, 0), 0);
3108 }
3109
3110 #[test]
3111 fn fenwick_visible_count_start_beyond_len() {
3112 let tracker = VariableHeightsFenwick::new(2, 5);
3113 let count = tracker.visible_count(100, 10);
3115 assert_eq!(count, 0);
3117 }
3118
3119 #[test]
3120 fn fenwick_clear_then_operations() {
3121 let mut tracker = VariableHeightsFenwick::new(3, 5);
3122 assert_eq!(tracker.total_height(), 15);
3123 tracker.clear();
3124 assert_eq!(tracker.len(), 0);
3125 assert_eq!(tracker.total_height(), 0);
3126 assert_eq!(tracker.find_item_at_offset(0), 0);
3127 }
3128
3129 #[test]
3130 fn fenwick_rebuild_replaces_data() {
3131 let mut tracker = VariableHeightsFenwick::new(1, 10);
3132 assert_eq!(tracker.total_height(), 10);
3133 tracker.rebuild(&[5, 3, 2]);
3134 assert_eq!(tracker.len(), 3);
3135 assert_eq!(tracker.total_height(), 10);
3136 assert_eq!(tracker.get(0), 5);
3137 assert_eq!(tracker.get(1), 3);
3138 assert_eq!(tracker.get(2), 2);
3139 }
3140
3141 #[test]
3142 fn fenwick_resize_same_size_is_noop() {
3143 let mut tracker = VariableHeightsFenwick::new(2, 5);
3144 tracker.set(2, 10);
3145 tracker.resize(5);
3146 assert_eq!(tracker.get(2), 10);
3148 assert_eq!(tracker.len(), 5);
3149 }
3150
3151 #[test]
3154 fn list_state_default_matches_new() {
3155 let d = VirtualizedListState::default();
3156 let n = VirtualizedListState::new();
3157 assert_eq!(d.selected, n.selected);
3158 assert_eq!(d.scroll_offset(), n.scroll_offset());
3159 assert_eq!(d.visible_count(), n.visible_count());
3160 assert_eq!(d.follow_mode(), n.follow_mode());
3161 }
3162
3163 #[test]
3164 fn list_state_select_next_on_empty() {
3165 let mut state = VirtualizedListState::new();
3166 state.select_next(0);
3167 assert_eq!(state.selected, None);
3168 }
3169
3170 #[test]
3171 fn list_state_select_previous_on_empty() {
3172 let mut state = VirtualizedListState::new();
3173 state.select_previous(0);
3174 assert_eq!(state.selected, None);
3175 }
3176
3177 #[test]
3178 fn list_state_select_previous_from_none() {
3179 let mut state = VirtualizedListState::new();
3180 state.select_previous(10);
3181 assert_eq!(state.selected, Some(0));
3182 }
3183
3184 #[test]
3185 fn list_state_select_next_from_none() {
3186 let mut state = VirtualizedListState::new();
3187 state.select_next(10);
3188 assert_eq!(state.selected, Some(0));
3189 }
3190
3191 #[test]
3192 fn list_state_scroll_zero_items() {
3193 let mut state = VirtualizedListState::new();
3194 state.scroll(10, 0);
3195 assert_eq!(state.scroll_offset(), 0);
3196 }
3197
3198 #[test]
3199 fn list_state_scroll_to_clamps() {
3200 let mut state = VirtualizedListState::new();
3201 state.scroll_to(100, 10);
3202 assert_eq!(state.scroll_offset(), 9);
3203 }
3204
3205 #[test]
3206 fn list_state_scroll_to_bottom_zero_items() {
3207 let mut state = VirtualizedListState::new();
3208 state.scroll_to_bottom(0);
3209 assert_eq!(state.scroll_offset(), 0);
3210 }
3211
3212 #[test]
3213 fn list_state_is_at_bottom_zero_items() {
3214 let state = VirtualizedListState::new();
3215 assert!(state.is_at_bottom(0));
3216 }
3217
3218 #[test]
3219 fn list_state_page_up_visible_count_zero() {
3220 let mut state = VirtualizedListState::new();
3221 state.scroll_offset = 5;
3222 state.page_up(20);
3223 assert_eq!(state.scroll_offset(), 5);
3225 }
3226
3227 #[test]
3228 fn list_state_page_down_visible_count_zero() {
3229 let mut state = VirtualizedListState::new();
3230 state.page_down(20);
3231 assert_eq!(state.scroll_offset(), 0);
3233 }
3234
3235 #[test]
3236 fn list_state_set_follow_false_no_scroll() {
3237 let mut state = VirtualizedListState::new();
3238 state.scroll_offset = 5;
3239 state.set_follow(false, 20);
3240 assert_eq!(state.scroll_offset(), 5); assert!(!state.follow_mode());
3242 }
3243
3244 #[test]
3245 fn list_state_persistence_id() {
3246 let state = VirtualizedListState::new().with_persistence_id("my-list");
3247 assert_eq!(state.persistence_id(), Some("my-list"));
3248 }
3249
3250 #[test]
3251 fn list_state_persistence_id_none() {
3252 let state = VirtualizedListState::new();
3253 assert_eq!(state.persistence_id(), None);
3254 }
3255
3256 #[test]
3257 fn list_state_momentum_tick_zero_items() {
3258 let mut state = VirtualizedListState::new();
3259 state.fling(50.0);
3260 state.tick(Duration::from_millis(100), 0);
3261 assert_eq!(state.scroll_offset(), 0);
3263 }
3264
3265 #[test]
3268 fn persist_state_default() {
3269 let ps = VirtualizedListPersistState::default();
3270 assert_eq!(ps.selected, None);
3271 assert_eq!(ps.scroll_offset, 0);
3272 assert!(!ps.follow_mode);
3273 }
3274
3275 #[test]
3276 fn persist_state_eq() {
3277 let a = VirtualizedListPersistState {
3278 selected: Some(5),
3279 scroll_offset: 10,
3280 follow_mode: true,
3281 };
3282 let b = a.clone();
3283 assert_eq!(a, b);
3284 }
3285
3286 #[test]
3289 fn stateful_state_key_with_persistence_id() {
3290 use crate::stateful::Stateful;
3291 let state = VirtualizedListState::new().with_persistence_id("logs");
3292 let key = state.state_key();
3293 assert_eq!(key.widget_type, "VirtualizedList");
3294 assert_eq!(key.instance_id, "logs");
3295 }
3296
3297 #[test]
3298 fn stateful_state_key_default_instance() {
3299 use crate::stateful::Stateful;
3300 let state = VirtualizedListState::new();
3301 let key = state.state_key();
3302 assert_eq!(key.instance_id, "default");
3303 }
3304
3305 #[test]
3306 fn stateful_save_restore_roundtrip() {
3307 use crate::stateful::Stateful;
3308 let mut state = VirtualizedListState::new();
3309 state.selected = Some(7);
3310 state.scroll_offset = 15;
3311 state.follow_mode = true;
3312 state.scroll_velocity = 42.0; let saved = state.save_state();
3315 assert_eq!(saved.selected, Some(7));
3316 assert_eq!(saved.scroll_offset, 15);
3317 assert!(saved.follow_mode);
3318
3319 let mut restored = VirtualizedListState::new();
3320 restored.scroll_velocity = 99.0;
3321 restored.restore_state(saved);
3322 assert_eq!(restored.selected, Some(7));
3323 assert_eq!(restored.scroll_offset, 15);
3324 assert!(restored.follow_mode);
3325 assert_eq!(restored.scroll_velocity, 0.0);
3327 }
3328
3329 #[test]
3332 fn virtualized_list_builder() {
3333 let items: Vec<String> = vec!["a".into()];
3334 let list = VirtualizedList::new(&items)
3335 .style(Style::default())
3336 .highlight_style(Style::default())
3337 .show_scrollbar(false)
3338 .fixed_height(3);
3339 assert_eq!(list.fixed_height, 3);
3340 assert!(!list.show_scrollbar);
3341 }
3342
3343 #[test]
3346 fn virtualized_storage_debug() {
3347 let storage: VirtualizedStorage<i32> = VirtualizedStorage::Owned(VecDeque::new());
3348 let dbg = format!("{:?}", storage);
3349 assert!(dbg.contains("Owned"));
3350
3351 let ext: VirtualizedStorage<i32> = VirtualizedStorage::External {
3352 len: 100,
3353 cache_capacity: 10,
3354 };
3355 let dbg = format!("{:?}", ext);
3356 assert!(dbg.contains("External"));
3357 }
3358
3359 #[test]
3360 fn test_virtualized_list_handle_mouse_drag_smooth() {
3361 use crate::scrollbar::SCROLLBAR_PART_THUMB;
3362 use ftui_core::event::{MouseButton, MouseEvent, MouseEventKind};
3363 use ftui_render::frame::{HitId, HitRegion};
3364
3365 let mut state = VirtualizedListState::new();
3366 let scrollbar_hit_id = HitId::new(1);
3367 let total_items = 100;
3368 let viewport_height = 10;
3369 let fixed_height = 1;
3370
3371 let track_len = 10u64;
3377 let track_pos = 0u64;
3378 let hit_data = (SCROLLBAR_PART_THUMB << 56)
3379 | ((track_len & 0x0FFF_FFFF) << 28)
3380 | (track_pos & 0x0FFF_FFFF);
3381
3382 let down_event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 0, 0);
3383 let hit = Some((scrollbar_hit_id, HitRegion::Scrollbar, hit_data));
3384
3385 state.handle_mouse(
3386 &down_event,
3387 hit,
3388 scrollbar_hit_id,
3389 total_items,
3390 viewport_height,
3391 fixed_height,
3392 );
3393
3394 assert!(
3395 state.scrollbar_drag_anchor.is_some(),
3396 "Drag anchor should be set on down"
3397 );
3398 assert_eq!(
3399 state.scrollbar_drag_anchor.unwrap(),
3400 0,
3401 "Anchor should be 0 (clicked top of thumb)"
3402 );
3403
3404 let drag_pos = 1u64;
3409 let drag_data = (SCROLLBAR_PART_THUMB << 56)
3410 | ((track_len & 0x0FFF_FFFF) << 28)
3411 | (drag_pos & 0x0FFF_FFFF);
3412 let drag_event = MouseEvent::new(MouseEventKind::Drag(MouseButton::Left), 0, 1);
3413 let drag_hit = Some((scrollbar_hit_id, HitRegion::Scrollbar, drag_data));
3414
3415 state.handle_mouse(
3416 &drag_event,
3417 drag_hit,
3418 scrollbar_hit_id,
3419 total_items,
3420 viewport_height,
3421 fixed_height,
3422 );
3423
3424 assert_eq!(
3425 state.scroll_offset, 10,
3426 "Scroll offset should update smoothly"
3427 );
3428 }
3429}