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 for _ in 0..iterations {
1953 small.scroll(1);
1954 small.scroll(-1);
1955 large.scroll(1);
1956 large.scroll(-1);
1957 }
1958
1959 let measure = |v: &mut Virtualized<i32>| -> std::time::Duration {
1964 let runs = 5;
1965 let mut best = std::time::Duration::MAX;
1966 for _ in 0..runs {
1967 let start = Instant::now();
1968 for _ in 0..iterations {
1969 v.scroll(1);
1970 v.scroll(-1);
1971 }
1972 best = best.min(start.elapsed());
1973 }
1974 best
1975 };
1976
1977 let small_time = measure(&mut small);
1978 let large_time = measure(&mut large);
1979
1980 let ceiling = small_time
1986 .checked_mul(10)
1987 .unwrap_or(std::time::Duration::MAX);
1988 assert!(
1989 large_time < ceiling,
1990 "Scroll is not O(1) (grows with list size): 1K={:?}, 100K={:?}",
1991 small_time,
1992 large_time
1993 );
1994 }
1995
1996 #[test]
1997 fn render_partially_offscreen_top_skips_item() {
1998 use ftui_render::grapheme_pool::GraphemePool;
1999
2000 struct IndexedItem(usize);
2002 impl RenderItem for IndexedItem {
2003 fn render(&self, area: Rect, frame: &mut Frame, _selected: bool, _skip_rows: u16) {
2004 let ch = char::from_digit(self.0 as u32, 10).unwrap();
2005 for y in area.y..area.bottom() {
2006 frame.buffer.set(area.x, y, Cell::from_char(ch));
2007 }
2008 }
2009 fn height(&self) -> u16 {
2010 2
2011 }
2012 }
2013
2014 let items = vec![
2017 IndexedItem(0),
2018 IndexedItem(1),
2019 IndexedItem(2),
2020 IndexedItem(3),
2021 ];
2022 let list = VirtualizedList::new(&items).fixed_height(2);
2023
2024 let mut state = VirtualizedListState::new().with_overscan(1);
2026 state.scroll_offset = 1; let mut pool = GraphemePool::new();
2029 let mut frame = Frame::new(10, 5, &mut pool);
2030
2031 list.render(Rect::new(0, 0, 10, 5), &mut frame, &mut state);
2033
2034 let cell = frame.buffer.get(0, 0).unwrap();
2042 assert_eq!(cell.content.as_char(), Some('1'));
2043 }
2044
2045 #[test]
2046 fn render_bottom_boundary_clips_partial_item() {
2047 use ftui_render::grapheme_pool::GraphemePool;
2048
2049 struct IndexedItem(u16);
2050 impl RenderItem for IndexedItem {
2051 fn render(&self, area: Rect, frame: &mut Frame, _selected: bool, _skip_rows: u16) {
2052 let ch = char::from_digit(self.0 as u32, 10).unwrap();
2053 for y in area.y..area.bottom() {
2054 frame.buffer.set(area.x, y, Cell::from_char(ch));
2055 }
2056 }
2057 fn height(&self) -> u16 {
2058 2
2059 }
2060 }
2061
2062 let items = vec![IndexedItem(0), IndexedItem(1), IndexedItem(2)];
2063 let list = VirtualizedList::new(&items)
2064 .fixed_height(2)
2065 .show_scrollbar(false);
2066 let mut state = VirtualizedListState::new();
2067
2068 let mut pool = GraphemePool::new();
2069 let mut frame = Frame::new(4, 4, &mut pool);
2070
2071 list.render(Rect::new(0, 0, 4, 3), &mut frame, &mut state);
2073
2074 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('0'));
2075 assert_eq!(frame.buffer.get(0, 1).unwrap().content.as_char(), Some('0'));
2076 assert_eq!(frame.buffer.get(0, 2).unwrap().content.as_char(), Some('1'));
2077 assert_eq!(frame.buffer.get(0, 3).unwrap().content.as_char(), None);
2079 }
2080
2081 #[test]
2082 fn render_after_fling_advances_visible_rows() {
2083 use ftui_render::grapheme_pool::GraphemePool;
2084
2085 struct IndexedItem(u16);
2086 impl RenderItem for IndexedItem {
2087 fn render(&self, area: Rect, frame: &mut Frame, _selected: bool, _skip_rows: u16) {
2088 let ch = char::from_digit(self.0 as u32, 10).unwrap();
2089 for y in area.y..area.bottom() {
2090 frame.buffer.set(area.x, y, Cell::from_char(ch));
2091 }
2092 }
2093 }
2094
2095 let items: Vec<IndexedItem> = (0..10).map(IndexedItem).collect();
2096 let list = VirtualizedList::new(&items)
2097 .fixed_height(1)
2098 .show_scrollbar(false);
2099 let mut state = VirtualizedListState::new();
2100
2101 let mut pool = GraphemePool::new();
2102 let mut frame = Frame::new(4, 3, &mut pool);
2103 let area = Rect::new(0, 0, 4, 3);
2104
2105 list.render(area, &mut frame, &mut state);
2107 assert_eq!(state.scroll_offset(), 0);
2108 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('0'));
2109
2110 state.fling(40.0);
2112 state.tick(Duration::from_millis(100), items.len());
2113 assert_eq!(state.scroll_offset(), 4);
2114
2115 frame.buffer.clear();
2116 list.render(area, &mut frame, &mut state);
2117 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('4'));
2118 }
2119
2120 #[test]
2121 fn render_empty_virtualized_list_clears_stale_viewport() {
2122 use ftui_render::grapheme_pool::GraphemePool;
2123
2124 let items: Vec<String> = Vec::new();
2125 let list = VirtualizedList::new(&items).show_scrollbar(false);
2126 let mut state = VirtualizedListState::new();
2127 let mut pool = GraphemePool::new();
2128 let mut frame = Frame::new(6, 3, &mut pool);
2129 let area = Rect::new(0, 0, 6, 3);
2130 frame.buffer.fill(area, Cell::from_char('X'));
2131
2132 list.render(area, &mut frame, &mut state);
2133
2134 assert_eq!(raw_row_text(&frame, 0), " ");
2135 assert_eq!(raw_row_text(&frame, 1), " ");
2136 assert_eq!(raw_row_text(&frame, 2), " ");
2137 }
2138
2139 #[test]
2140 fn render_shorter_virtualized_row_clears_stale_suffix() {
2141 use ftui_render::grapheme_pool::GraphemePool;
2142
2143 let long_items = vec!["Hello".to_string()];
2144 let short_items = vec!["Hi".to_string()];
2145 let area = Rect::new(0, 0, 6, 1);
2146 let mut state = VirtualizedListState::new();
2147 let mut pool = GraphemePool::new();
2148 let mut frame = Frame::new(6, 1, &mut pool);
2149
2150 VirtualizedList::new(&long_items)
2151 .show_scrollbar(false)
2152 .render(area, &mut frame, &mut state);
2153 VirtualizedList::new(&short_items)
2154 .show_scrollbar(false)
2155 .render(area, &mut frame, &mut state);
2156
2157 assert_eq!(raw_row_text(&frame, 0), "Hi ");
2158 }
2159
2160 #[test]
2161 fn test_memory_bounded_by_ring_capacity() {
2162 use crate::log_ring::LogRing;
2163
2164 let mut ring: LogRing<String> = LogRing::new(1_000);
2165
2166 for i in 0..100_000 {
2168 ring.push(format!("Line {}", i));
2169 }
2170
2171 assert_eq!(ring.len(), 1_000);
2173 assert_eq!(ring.total_count(), 100_000);
2174 assert_eq!(ring.first_index(), 99_000);
2175
2176 assert!(ring.get(99_999).is_some());
2178 assert!(ring.get(99_000).is_some());
2179 assert!(ring.get(0).is_none());
2181 assert!(ring.get(98_999).is_none());
2182 }
2183
2184 #[test]
2185 fn test_visible_range_constant_regardless_of_total() {
2186 let mut small: Virtualized<i32> = Virtualized::new(100);
2187 for i in 0..100 {
2188 small.push(i);
2189 }
2190 let small_range = small.visible_range(24);
2191
2192 let mut large: Virtualized<i32> = Virtualized::new(100_000);
2193 for i in 0..100_000 {
2194 large.push(i);
2195 }
2196 let large_range = large.visible_range(24);
2197
2198 assert_eq!(small_range.end - small_range.start, 24);
2200 assert_eq!(large_range.end - large_range.start, 24);
2201 }
2202
2203 #[test]
2204 fn test_virtualized_list_state_page_up_down() {
2205 let mut state = VirtualizedListState::new();
2206 state.visible_count = 10;
2207
2208 state.page_down(50);
2210 assert_eq!(state.scroll_offset(), 9);
2211
2212 state.page_down(50);
2214 assert_eq!(state.scroll_offset(), 18);
2215
2216 state.page_up(50);
2218 assert_eq!(state.scroll_offset(), 9);
2219
2220 state.page_up(50);
2222 assert_eq!(state.scroll_offset(), 0);
2223 }
2224
2225 #[test]
2230 fn test_variable_heights_fenwick_new() {
2231 let tracker = VariableHeightsFenwick::new(2, 10);
2232 assert_eq!(tracker.len(), 10);
2233 assert!(!tracker.is_empty());
2234 assert_eq!(tracker.default_height(), 2);
2235 }
2236
2237 #[test]
2238 fn test_variable_heights_fenwick_empty() {
2239 let tracker = VariableHeightsFenwick::new(1, 0);
2240 assert!(tracker.is_empty());
2241 assert_eq!(tracker.total_height(), 0);
2242 }
2243
2244 #[test]
2245 fn test_variable_heights_fenwick_from_heights() {
2246 let heights = vec![3, 2, 5, 1, 4];
2247 let tracker = VariableHeightsFenwick::from_heights(&heights, 1);
2248
2249 assert_eq!(tracker.len(), 5);
2250 assert_eq!(tracker.get(0), 3);
2251 assert_eq!(tracker.get(1), 2);
2252 assert_eq!(tracker.get(2), 5);
2253 assert_eq!(tracker.get(3), 1);
2254 assert_eq!(tracker.get(4), 4);
2255 assert_eq!(tracker.total_height(), 15);
2256 }
2257
2258 #[test]
2259 fn test_variable_heights_fenwick_offset_of_item() {
2260 let heights = vec![3, 2, 5, 1, 4];
2262 let tracker = VariableHeightsFenwick::from_heights(&heights, 1);
2263
2264 assert_eq!(tracker.offset_of_item(0), 0);
2265 assert_eq!(tracker.offset_of_item(1), 3);
2266 assert_eq!(tracker.offset_of_item(2), 5);
2267 assert_eq!(tracker.offset_of_item(3), 10);
2268 assert_eq!(tracker.offset_of_item(4), 11);
2269 assert_eq!(tracker.offset_of_item(5), 15); }
2271
2272 #[test]
2273 fn test_variable_heights_fenwick_find_item_at_offset() {
2274 let heights = vec![3, 2, 5, 1, 4];
2276 let tracker = VariableHeightsFenwick::from_heights(&heights, 1);
2277
2278 assert_eq!(tracker.find_item_at_offset(0), 0);
2280 assert_eq!(tracker.find_item_at_offset(1), 0);
2282 assert_eq!(tracker.find_item_at_offset(3), 1);
2284 assert_eq!(tracker.find_item_at_offset(5), 2);
2286 assert_eq!(tracker.find_item_at_offset(10), 3);
2288 assert_eq!(tracker.find_item_at_offset(11), 4);
2290 assert_eq!(tracker.find_item_at_offset(15), 5);
2292 }
2293
2294 #[test]
2295 fn test_variable_heights_fenwick_visible_count() {
2296 let heights = vec![3, 2, 5, 1, 4];
2298 let tracker = VariableHeightsFenwick::from_heights(&heights, 1);
2299
2300 assert_eq!(tracker.visible_count(0, 5), 2);
2302
2303 assert_eq!(tracker.visible_count(0, 4), 2);
2305
2306 assert_eq!(tracker.visible_count(0, 10), 3);
2308
2309 assert_eq!(tracker.visible_count(2, 6), 2);
2311 }
2312
2313 #[test]
2314 fn test_variable_heights_fenwick_visible_count_viewport_beyond_total_height() {
2315 let heights = vec![1, 1, 1];
2316 let tracker = VariableHeightsFenwick::from_heights(&heights, 1);
2317
2318 assert_eq!(tracker.visible_count(0, 10), 3);
2320 assert_eq!(tracker.visible_count(1, 10), 2);
2321 assert_eq!(tracker.visible_count(2, 10), 1);
2322 }
2323
2324 #[test]
2325 fn test_variable_heights_fenwick_set() {
2326 let mut tracker = VariableHeightsFenwick::new(1, 5);
2327
2328 assert_eq!(tracker.get(0), 1);
2330 assert_eq!(tracker.total_height(), 5);
2331
2332 tracker.set(2, 10);
2334 assert_eq!(tracker.get(2), 10);
2335 assert_eq!(tracker.total_height(), 14); }
2337
2338 #[test]
2339 fn test_variable_heights_fenwick_resize() {
2340 let mut tracker = VariableHeightsFenwick::new(2, 3);
2341 assert_eq!(tracker.len(), 3);
2342 assert_eq!(tracker.total_height(), 6);
2343
2344 tracker.resize(5);
2346 assert_eq!(tracker.len(), 5);
2347 assert_eq!(tracker.total_height(), 10);
2348 assert_eq!(tracker.get(4), 2);
2349
2350 tracker.resize(2);
2352 assert_eq!(tracker.len(), 2);
2353 assert_eq!(tracker.total_height(), 4);
2354 }
2355
2356 #[test]
2357 fn test_variable_heights_fenwick_point_update_and_range_query() {
2358 fn range_sum(tracker: &VariableHeightsFenwick, left: usize, right: usize) -> u32 {
2359 tracker
2360 .offset_of_item(right.saturating_add(1))
2361 .saturating_sub(tracker.offset_of_item(left))
2362 }
2363
2364 let mut tracker = VariableHeightsFenwick::from_heights(&[2, 4, 1, 3, 5, 2], 1);
2365 let mut naive = [2_u32, 4, 1, 3, 5, 2];
2366
2367 tracker.set(2, 7);
2368 naive[2] = 7;
2369 tracker.set(5, 1);
2370 naive[5] = 1;
2371 tracker.set(0, 6);
2372 naive[0] = 6;
2373
2374 let mut running = 0u32;
2375 for (i, value) in naive.iter().enumerate() {
2376 running = running.saturating_add(*value);
2377 assert_eq!(tracker.offset_of_item(i + 1), running);
2378 }
2379
2380 let naive_sum = |left: usize, right: usize| -> u32 { naive[left..=right].iter().sum() };
2381 assert_eq!(range_sum(&tracker, 0, 0), naive_sum(0, 0));
2382 assert_eq!(range_sum(&tracker, 1, 3), naive_sum(1, 3));
2383 assert_eq!(range_sum(&tracker, 2, 5), naive_sum(2, 5));
2384 }
2385
2386 proptest! {
2387 #![proptest_config(ProptestConfig::with_cases(96))]
2388
2389 #[test]
2390 fn property_variable_heights_fenwick_prefix_sums_match_naive(
2391 heights in proptest::collection::vec(1u16..=32u16, 1..160)
2392 ) {
2393 let tracker = VariableHeightsFenwick::from_heights(&heights, 1);
2394
2395 let mut naive_prefix = 0u32;
2396 prop_assert_eq!(tracker.offset_of_item(0), 0);
2397 for (i, height) in heights.iter().enumerate() {
2398 naive_prefix = naive_prefix.saturating_add(u32::from(*height));
2399 prop_assert_eq!(
2400 tracker.offset_of_item(i + 1),
2401 naive_prefix,
2402 "prefix mismatch at index {} for heights {:?}",
2403 i,
2404 heights
2405 );
2406 }
2407 }
2408
2409 #[test]
2410 fn property_variable_heights_fenwick_visible_count_matches_naive(
2411 heights in proptest::collection::vec(1u16..=24u16, 1..128),
2412 start_idx in 0usize..192usize,
2413 viewport_height in 1u16..=120u16
2414 ) {
2415 fn naive_visible_count(heights: &[u16], start_idx: usize, viewport_height: u16) -> usize {
2416 if heights.is_empty() || viewport_height == 0 {
2417 return 0;
2418 }
2419 let start = start_idx.min(heights.len());
2420 if start >= heights.len() {
2421 return 0;
2422 }
2423
2424 let start_offset: u32 = heights[..start].iter().map(|&h| u32::from(h)).sum();
2425 let end_offset = start_offset.saturating_add(u32::from(viewport_height));
2426 let mut count = 0usize;
2427 let mut cursor = start_offset;
2428 for &height in &heights[start..] {
2429 if cursor >= end_offset {
2430 break;
2431 }
2432 count = count.saturating_add(1);
2433 cursor = cursor.saturating_add(u32::from(height));
2434 }
2435
2436 if count == 0 { 1 } else { count }
2437 }
2438
2439 let tracker = VariableHeightsFenwick::from_heights(&heights, 1);
2440 let expected = naive_visible_count(&heights, start_idx, viewport_height);
2441 let actual = tracker.visible_count(start_idx, viewport_height);
2442 prop_assert_eq!(
2443 actual,
2444 expected,
2445 "visible_count mismatch for start={} viewport={} heights={:?}",
2446 start_idx,
2447 viewport_height,
2448 heights
2449 );
2450 }
2451 }
2452
2453 #[test]
2454 fn test_virtualized_with_variable_heights_fenwick() {
2455 let mut virt: Virtualized<i32> = Virtualized::new(100).with_variable_heights_fenwick(2, 10);
2456
2457 for i in 0..10 {
2458 virt.push(i);
2459 }
2460
2461 let range = virt.visible_range(6);
2463 assert_eq!(range.end - range.start, 3);
2464 }
2465
2466 #[test]
2467 fn test_variable_heights_fenwick_performance() {
2468 use std::time::Instant;
2469
2470 let n = 100_000;
2472 let heights: Vec<u16> = (0..n).map(|i| (i % 10 + 1) as u16).collect();
2473 let tracker = VariableHeightsFenwick::from_heights(&heights, 1);
2474
2475 let _ = tracker.find_item_at_offset(500_000);
2477 let _ = tracker.offset_of_item(50_000);
2478
2479 let start = Instant::now();
2481 let mut _sink = 0usize;
2482 for i in 0..10_000 {
2483 _sink = _sink.wrapping_add(tracker.find_item_at_offset((i * 50) as u32));
2484 }
2485 let find_time = start.elapsed();
2486
2487 let start = Instant::now();
2489 let mut _sink2 = 0u32;
2490 for i in 0..10_000 {
2491 _sink2 = _sink2.wrapping_add(tracker.offset_of_item((i * 10) % n));
2492 }
2493 let offset_time = start.elapsed();
2494
2495 eprintln!("=== VariableHeightsFenwick Performance (n={n}) ===");
2496 eprintln!("10k find_item_at_offset: {:?}", find_time);
2497 eprintln!("10k offset_of_item: {:?}", offset_time);
2498
2499 assert!(
2501 find_time < std::time::Duration::from_millis(50),
2502 "find_item_at_offset too slow: {:?}",
2503 find_time
2504 );
2505 assert!(
2506 offset_time < std::time::Duration::from_millis(50),
2507 "offset_of_item too slow: {:?}",
2508 offset_time
2509 );
2510 }
2511
2512 #[test]
2513 fn test_variable_heights_fenwick_scales_logarithmically() {
2514 use std::time::Instant;
2515
2516 let small_n = 1_000;
2518 let small_heights: Vec<u16> = (0..small_n).map(|i| (i % 5 + 1) as u16).collect();
2519 let small_tracker = VariableHeightsFenwick::from_heights(&small_heights, 1);
2520
2521 let large_n = 100_000;
2523 let large_heights: Vec<u16> = (0..large_n).map(|i| (i % 5 + 1) as u16).collect();
2524 let large_tracker = VariableHeightsFenwick::from_heights(&large_heights, 1);
2525
2526 let iterations = 5_000;
2527
2528 let start = Instant::now();
2530 for i in 0..iterations {
2531 let _ = small_tracker.find_item_at_offset((i * 2) as u32);
2532 }
2533 let small_time = start.elapsed();
2534
2535 let start = Instant::now();
2537 for i in 0..iterations {
2538 let _ = large_tracker.find_item_at_offset((i * 200) as u32);
2539 }
2540 let large_time = start.elapsed();
2541
2542 assert!(
2545 large_time < small_time * 10,
2546 "Not O(log n): small={:?}, large={:?}",
2547 small_time,
2548 large_time
2549 );
2550 }
2551
2552 #[test]
2559 fn new_zero_capacity() {
2560 let virt: Virtualized<i32> = Virtualized::new(0);
2561 assert_eq!(virt.len(), 0);
2562 assert!(virt.is_empty());
2563 assert_eq!(virt.scroll_offset(), 0);
2564 assert_eq!(virt.visible_count(), 0);
2565 assert!(!virt.follow_mode());
2566 }
2567
2568 #[test]
2569 fn external_zero_len_zero_cache() {
2570 let virt: Virtualized<i32> = Virtualized::external(0, 0);
2571 assert_eq!(virt.len(), 0);
2572 assert!(virt.is_empty());
2573 }
2574
2575 #[test]
2576 fn external_storage_returns_none_for_get() {
2577 let virt: Virtualized<i32> = Virtualized::external(100, 10);
2578 assert_eq!(virt.get(0), None);
2579 assert_eq!(virt.get(50), None);
2580 }
2581
2582 #[test]
2583 fn external_storage_returns_none_for_get_mut() {
2584 let mut virt: Virtualized<i32> = Virtualized::external(100, 10);
2585 assert!(virt.get_mut(0).is_none());
2586 }
2587
2588 #[test]
2589 fn push_on_external_is_noop() {
2590 let mut virt: Virtualized<i32> = Virtualized::external(5, 10);
2591 virt.push(42);
2592 assert_eq!(virt.len(), 5);
2594 }
2595
2596 #[test]
2597 fn iter_on_external_is_empty() {
2598 let virt: Virtualized<i32> = Virtualized::external(100, 10);
2599 assert_eq!(virt.iter().count(), 0);
2600 }
2601
2602 #[test]
2603 fn set_external_len_on_owned_is_noop() {
2604 let mut virt: Virtualized<i32> = Virtualized::new(100);
2605 virt.push(1);
2606 virt.set_external_len(999);
2607 assert_eq!(virt.len(), 1); }
2609
2610 #[test]
2613 fn visible_range_zero_viewport() {
2614 let mut virt: Virtualized<i32> = Virtualized::new(100);
2615 virt.push(1);
2616 let range = virt.visible_range(0);
2617 assert_eq!(range, 0..0);
2618 assert_eq!(virt.visible_count(), 0);
2619 }
2620
2621 #[test]
2622 fn visible_range_empty_container() {
2623 let virt: Virtualized<i32> = Virtualized::new(100);
2624 let range = virt.visible_range(24);
2625 assert_eq!(range, 0..0);
2626 }
2627
2628 #[test]
2629 fn visible_range_fixed_height_zero() {
2630 let mut virt: Virtualized<i32> = Virtualized::new(100).with_fixed_height(0);
2632 for i in 0..10 {
2633 virt.push(i);
2634 }
2635 let range = virt.visible_range(5);
2636 assert_eq!(range, 0..5);
2638 }
2639
2640 #[test]
2641 fn visible_range_fewer_items_than_viewport() {
2642 let mut virt: Virtualized<i32> = Virtualized::new(100);
2643 for i in 0..3 {
2644 virt.push(i);
2645 }
2646 let range = virt.visible_range(24);
2647 assert_eq!(range, 0..3);
2649 }
2650
2651 #[test]
2652 fn visible_range_single_item() {
2653 let mut virt: Virtualized<i32> = Virtualized::new(100);
2654 virt.push(42);
2655 let range = virt.visible_range(1);
2656 assert_eq!(range, 0..1);
2657 }
2658
2659 #[test]
2662 fn render_range_at_start_clamps_overscan() {
2663 let mut virt: Virtualized<i32> =
2664 Virtualized::new(100).with_fixed_height(1).with_overscan(5);
2665 for i in 0..20 {
2666 virt.push(i);
2667 }
2668 let range = virt.render_range(10);
2670 assert_eq!(range.start, 0);
2671 }
2672
2673 #[test]
2674 fn render_range_at_end_clamps_overscan() {
2675 let mut virt: Virtualized<i32> =
2676 Virtualized::new(100).with_fixed_height(1).with_overscan(5);
2677 for i in 0..20 {
2678 virt.push(i);
2679 }
2680 virt.set_visible_count(10);
2681 virt.scroll_to(10); let range = virt.render_range(10);
2683 assert_eq!(range.end, 20);
2685 }
2686
2687 #[test]
2688 fn render_range_zero_overscan() {
2689 let mut virt: Virtualized<i32> =
2690 Virtualized::new(100).with_fixed_height(1).with_overscan(0);
2691 for i in 0..20 {
2692 virt.push(i);
2693 }
2694 virt.set_visible_count(10);
2695 virt.scroll_to(5);
2696 let range = virt.render_range(10);
2697 let visible = virt.visible_range(10);
2699 assert_eq!(range, visible);
2700 }
2701
2702 #[test]
2705 fn scroll_on_empty_is_noop() {
2706 let mut virt: Virtualized<i32> = Virtualized::new(100);
2707 virt.scroll(10);
2708 assert_eq!(virt.scroll_offset(), 0);
2709 }
2710
2711 #[test]
2712 fn scroll_delta_zero_does_not_disable_follow() {
2713 let mut virt: Virtualized<i32> = Virtualized::new(100).with_follow(true);
2714 virt.push(1);
2715 virt.scroll(0);
2716 assert!(virt.follow_mode());
2718 }
2719
2720 #[test]
2721 fn scroll_negative_beyond_start() {
2722 let mut virt: Virtualized<i32> = Virtualized::new(100);
2723 for i in 0..10 {
2724 virt.push(i);
2725 }
2726 virt.scroll(-1);
2727 assert_eq!(virt.scroll_offset(), 0);
2728 }
2729
2730 #[test]
2731 fn scroll_to_on_empty() {
2732 let mut virt: Virtualized<i32> = Virtualized::new(100);
2733 virt.scroll_to(100);
2735 assert_eq!(virt.scroll_offset(), 0);
2736 }
2737
2738 #[test]
2739 fn scroll_to_top_already_at_top() {
2740 let mut virt: Virtualized<i32> = Virtualized::new(100);
2741 virt.push(1);
2742 virt.scroll_to_top();
2743 assert_eq!(virt.scroll_offset(), 0);
2744 }
2745
2746 #[test]
2747 fn scroll_to_bottom_fewer_items_than_visible() {
2748 let mut virt: Virtualized<i32> = Virtualized::new(100);
2749 virt.set_visible_count(10);
2750 for i in 0..3 {
2751 virt.push(i);
2752 }
2753 virt.scroll_to_bottom();
2754 assert_eq!(virt.scroll_offset(), 0);
2756 }
2757
2758 #[test]
2759 fn scroll_to_bottom_visible_count_zero() {
2760 let mut virt: Virtualized<i32> = Virtualized::new(100);
2761 for i in 0..20 {
2762 virt.push(i);
2763 }
2764 virt.scroll_to_bottom();
2766 assert_eq!(virt.scroll_offset, usize::MAX);
2768 assert_eq!(virt.scroll_offset(), 19);
2770 }
2771
2772 #[test]
2775 fn page_up_visible_count_zero_is_noop() {
2776 let mut virt: Virtualized<i32> = Virtualized::new(100);
2777 for i in 0..20 {
2778 virt.push(i);
2779 }
2780 virt.scroll_to(10);
2781 virt.page_up();
2783 assert_eq!(virt.scroll_offset(), 10);
2784 }
2785
2786 #[test]
2787 fn page_down_visible_count_zero_is_noop() {
2788 let mut virt: Virtualized<i32> = Virtualized::new(100);
2789 for i in 0..20 {
2790 virt.push(i);
2791 }
2792 virt.page_down();
2794 assert_eq!(virt.scroll_offset(), 0);
2795 }
2796
2797 #[test]
2800 fn is_at_bottom_fewer_items_than_visible() {
2801 let mut virt: Virtualized<i32> = Virtualized::new(100);
2802 virt.set_visible_count(10);
2803 for i in 0..3 {
2804 virt.push(i);
2805 }
2806 assert!(virt.is_at_bottom());
2807 }
2808
2809 #[test]
2810 fn is_at_bottom_empty() {
2811 let virt: Virtualized<i32> = Virtualized::new(100);
2812 assert!(virt.is_at_bottom());
2814 }
2815
2816 #[test]
2819 fn trim_front_under_max_returns_zero() {
2820 let mut virt: Virtualized<i32> = Virtualized::new(100);
2821 for i in 0..5 {
2822 virt.push(i);
2823 }
2824 let removed = virt.trim_front(10);
2825 assert_eq!(removed, 0);
2826 assert_eq!(virt.len(), 5);
2827 }
2828
2829 #[test]
2830 fn trim_front_adjusts_scroll_offset() {
2831 let mut virt: Virtualized<i32> = Virtualized::new(100);
2832 for i in 0..20 {
2833 virt.push(i);
2834 }
2835 virt.scroll_to(10);
2836 let removed = virt.trim_front(15);
2837 assert_eq!(removed, 5);
2838 assert_eq!(virt.len(), 15);
2839 assert_eq!(virt.scroll_offset(), 5);
2841 }
2842
2843 #[test]
2844 fn trim_front_scroll_offset_saturates_to_zero() {
2845 let mut virt: Virtualized<i32> = Virtualized::new(100);
2846 for i in 0..20 {
2847 virt.push(i);
2848 }
2849 virt.scroll_to(2);
2850 let removed = virt.trim_front(10);
2851 assert_eq!(removed, 10);
2852 assert_eq!(virt.scroll_offset(), 0);
2854 }
2855
2856 #[test]
2857 fn trim_front_on_external_returns_zero() {
2858 let mut virt: Virtualized<i32> = Virtualized::external(100, 10);
2859 let removed = virt.trim_front(5);
2860 assert_eq!(removed, 0);
2861 }
2862
2863 #[test]
2864 fn scroll_to_bottom_sets_sentinel_for_lazy_clamping() {
2865 let mut virt: Virtualized<i32> = Virtualized::new(100);
2866 for i in 0..20 {
2867 virt.push(i);
2868 }
2869
2870 virt.scroll_to_bottom();
2873 assert_eq!(virt.scroll_offset, usize::MAX);
2874
2875 let range = virt.visible_range(10);
2878 assert_eq!(range, 10..20);
2879 assert_eq!(virt.visible_count(), 10);
2880 }
2881
2882 #[test]
2885 fn clear_on_external_resets_scroll() {
2886 let mut virt: Virtualized<i32> = Virtualized::external(100, 10);
2887 virt.scroll_to(50);
2888 virt.clear();
2889 assert_eq!(virt.scroll_offset(), 0);
2890 assert_eq!(virt.len(), 100);
2892 }
2893
2894 #[test]
2897 fn tick_zero_velocity_is_noop() {
2898 let mut virt: Virtualized<i32> = Virtualized::new(100);
2899 for i in 0..20 {
2900 virt.push(i);
2901 }
2902 virt.tick(Duration::from_millis(100));
2903 assert_eq!(virt.scroll_offset(), 0);
2904 }
2905
2906 #[test]
2907 fn tick_below_threshold_stops_momentum() {
2908 let mut virt: Virtualized<i32> = Virtualized::new(100);
2909 for i in 0..20 {
2910 virt.push(i);
2911 }
2912 virt.fling(0.05); virt.tick(Duration::from_millis(100));
2914 assert_eq!(virt.scroll_offset(), 0);
2916 }
2917
2918 #[test]
2919 fn tick_zero_duration_no_scroll() {
2920 let mut virt: Virtualized<i32> = Virtualized::new(100);
2921 for i in 0..50 {
2922 virt.push(i);
2923 }
2924 virt.fling(100.0);
2925 virt.tick(Duration::ZERO);
2926 assert_eq!(virt.scroll_offset(), 0);
2928 }
2929
2930 #[test]
2931 fn fling_negative_scrolls_up() {
2932 let mut virt: Virtualized<i32> = Virtualized::new(100);
2933 for i in 0..50 {
2934 virt.push(i);
2935 }
2936 virt.scroll(20);
2937 let before = virt.scroll_offset();
2938 virt.fling(-50.0);
2939 virt.tick(Duration::from_millis(100));
2940 assert!(virt.scroll_offset() < before);
2941 }
2942
2943 #[test]
2946 fn follow_mode_auto_scrolls_on_push() {
2947 let mut virt: Virtualized<i32> = Virtualized::new(100).with_follow(true);
2948 virt.set_visible_count(5);
2949 for i in 0..20 {
2950 virt.push(i);
2951 }
2952 assert!(virt.is_at_bottom());
2954 assert_eq!(virt.scroll_offset(), 15); }
2956
2957 #[test]
2958 fn set_follow_false_does_not_scroll() {
2959 let mut virt: Virtualized<i32> = Virtualized::new(100);
2960 virt.set_visible_count(5);
2961 for i in 0..20 {
2962 virt.push(i);
2963 }
2964 virt.scroll_to(5);
2965 virt.set_follow(false);
2966 assert_eq!(virt.scroll_offset(), 5); }
2968
2969 #[test]
2970 fn scroll_to_start_disables_follow() {
2971 let mut virt: Virtualized<i32> = Virtualized::new(100).with_follow(true);
2972 virt.set_visible_count(5);
2973 for i in 0..20 {
2974 virt.push(i);
2975 }
2976 virt.scroll_to_start();
2977 assert!(!virt.follow_mode());
2978 assert_eq!(virt.scroll_offset(), 0);
2979 }
2980
2981 #[test]
2982 fn scroll_to_end_enables_follow() {
2983 let mut virt: Virtualized<i32> = Virtualized::new(100);
2984 virt.set_visible_count(5);
2985 for i in 0..20 {
2986 virt.push(i);
2987 }
2988 assert!(!virt.follow_mode());
2989 virt.scroll_to_end();
2990 assert!(virt.follow_mode());
2991 assert!(virt.is_at_bottom());
2992 }
2993
2994 #[test]
2995 fn external_follow_mode_scrolls_on_set_external_len() {
2996 let mut virt: Virtualized<i32> = Virtualized::external(10, 100).with_follow(true);
2997 virt.set_visible_count(5);
2998 virt.set_external_len(20);
2999 assert_eq!(virt.len(), 20);
3000 assert!(virt.is_at_bottom());
3001 }
3002
3003 #[test]
3006 fn builder_chain_all_options() {
3007 let virt: Virtualized<i32> = Virtualized::new(100)
3008 .with_fixed_height(3)
3009 .with_overscan(5)
3010 .with_follow(true);
3011 assert!(virt.follow_mode());
3012 let range = virt.visible_range(9);
3015 assert_eq!(range, 0..0);
3016 }
3017
3018 #[test]
3021 fn height_cache_default() {
3022 let cache = HeightCache::default();
3023 assert_eq!(cache.get(0), 1); assert_eq!(cache.capacity, 1000);
3025 }
3026
3027 #[test]
3028 fn height_cache_get_before_base_offset() {
3029 let mut cache = HeightCache::new(5, 100);
3030 cache.set(200, 10); assert_eq!(cache.get(0), 5);
3034 }
3035
3036 #[test]
3037 fn height_cache_set_before_base_offset_ignored() {
3038 let mut cache = HeightCache::new(5, 100);
3039 cache.set(200, 10);
3040 let base = cache.base_offset;
3041 cache.set(0, 99); assert_eq!(cache.get(0), 5); assert_eq!(cache.base_offset, base); }
3045
3046 #[test]
3047 fn height_cache_capacity_zero_ignores_all_sets() {
3048 let mut cache = HeightCache::new(3, 0);
3049 cache.set(0, 10);
3050 cache.set(5, 20);
3051 assert_eq!(cache.get(0), 3);
3053 assert_eq!(cache.get(5), 3);
3054 }
3055
3056 #[test]
3057 fn height_cache_clear_resets_base() {
3058 let mut cache = HeightCache::new(1, 100);
3059 cache.set(50, 10);
3060 cache.clear();
3061 assert_eq!(cache.base_offset, 0);
3062 assert_eq!(cache.get(50), 1); }
3064
3065 #[test]
3066 fn height_cache_eviction_trims_oldest() {
3067 let mut cache = HeightCache::new(1, 4);
3068 for i in 0..6 {
3070 cache.set(i, (i + 10) as u16);
3071 }
3072 assert!(cache.cache.len() <= cache.capacity);
3074 assert_eq!(cache.get(5), 15);
3076 assert_eq!(cache.get(4), 14);
3077 assert_eq!(cache.get(3), 13);
3078 assert_eq!(cache.get(2), 12);
3079 assert_eq!(cache.get(1), 1);
3081 assert_eq!(cache.get(0), 1);
3082 }
3083
3084 #[test]
3087 fn fenwick_default_is_empty() {
3088 let tracker = VariableHeightsFenwick::default();
3089 assert!(tracker.is_empty());
3090 assert_eq!(tracker.len(), 0);
3091 assert_eq!(tracker.total_height(), 0);
3092 assert_eq!(tracker.default_height(), 1);
3093 }
3094
3095 #[test]
3096 fn fenwick_get_beyond_len_returns_default() {
3097 let tracker = VariableHeightsFenwick::new(3, 5);
3098 assert_eq!(tracker.get(5), 3); assert_eq!(tracker.get(100), 3);
3100 }
3101
3102 #[test]
3103 fn fenwick_set_beyond_len_resizes() {
3104 let mut tracker = VariableHeightsFenwick::new(2, 3);
3105 assert_eq!(tracker.len(), 3);
3106 tracker.set(10, 7);
3107 assert!(tracker.len() > 10);
3108 assert_eq!(tracker.get(10), 7);
3109 }
3110
3111 #[test]
3112 fn fenwick_offset_of_item_zero_always_zero() {
3113 let tracker = VariableHeightsFenwick::new(5, 10);
3114 assert_eq!(tracker.offset_of_item(0), 0);
3115
3116 let empty = VariableHeightsFenwick::new(5, 0);
3117 assert_eq!(empty.offset_of_item(0), 0);
3118 }
3119
3120 #[test]
3121 fn fenwick_find_item_at_offset_empty() {
3122 let tracker = VariableHeightsFenwick::new(1, 0);
3123 assert_eq!(tracker.find_item_at_offset(0), 0);
3124 assert_eq!(tracker.find_item_at_offset(100), 0);
3125 }
3126
3127 #[test]
3128 fn fenwick_visible_count_zero_viewport() {
3129 let tracker = VariableHeightsFenwick::new(2, 10);
3130 assert_eq!(tracker.visible_count(0, 0), 0);
3131 }
3132
3133 #[test]
3134 fn fenwick_visible_count_start_beyond_len() {
3135 let tracker = VariableHeightsFenwick::new(2, 5);
3136 let count = tracker.visible_count(100, 10);
3138 assert_eq!(count, 0);
3140 }
3141
3142 #[test]
3143 fn fenwick_clear_then_operations() {
3144 let mut tracker = VariableHeightsFenwick::new(3, 5);
3145 assert_eq!(tracker.total_height(), 15);
3146 tracker.clear();
3147 assert_eq!(tracker.len(), 0);
3148 assert_eq!(tracker.total_height(), 0);
3149 assert_eq!(tracker.find_item_at_offset(0), 0);
3150 }
3151
3152 #[test]
3153 fn fenwick_rebuild_replaces_data() {
3154 let mut tracker = VariableHeightsFenwick::new(1, 10);
3155 assert_eq!(tracker.total_height(), 10);
3156 tracker.rebuild(&[5, 3, 2]);
3157 assert_eq!(tracker.len(), 3);
3158 assert_eq!(tracker.total_height(), 10);
3159 assert_eq!(tracker.get(0), 5);
3160 assert_eq!(tracker.get(1), 3);
3161 assert_eq!(tracker.get(2), 2);
3162 }
3163
3164 #[test]
3165 fn fenwick_resize_same_size_is_noop() {
3166 let mut tracker = VariableHeightsFenwick::new(2, 5);
3167 tracker.set(2, 10);
3168 tracker.resize(5);
3169 assert_eq!(tracker.get(2), 10);
3171 assert_eq!(tracker.len(), 5);
3172 }
3173
3174 #[test]
3177 fn list_state_default_matches_new() {
3178 let d = VirtualizedListState::default();
3179 let n = VirtualizedListState::new();
3180 assert_eq!(d.selected, n.selected);
3181 assert_eq!(d.scroll_offset(), n.scroll_offset());
3182 assert_eq!(d.visible_count(), n.visible_count());
3183 assert_eq!(d.follow_mode(), n.follow_mode());
3184 }
3185
3186 #[test]
3187 fn list_state_select_next_on_empty() {
3188 let mut state = VirtualizedListState::new();
3189 state.select_next(0);
3190 assert_eq!(state.selected, None);
3191 }
3192
3193 #[test]
3194 fn list_state_select_previous_on_empty() {
3195 let mut state = VirtualizedListState::new();
3196 state.select_previous(0);
3197 assert_eq!(state.selected, None);
3198 }
3199
3200 #[test]
3201 fn list_state_select_previous_from_none() {
3202 let mut state = VirtualizedListState::new();
3203 state.select_previous(10);
3204 assert_eq!(state.selected, Some(0));
3205 }
3206
3207 #[test]
3208 fn list_state_select_next_from_none() {
3209 let mut state = VirtualizedListState::new();
3210 state.select_next(10);
3211 assert_eq!(state.selected, Some(0));
3212 }
3213
3214 #[test]
3215 fn list_state_scroll_zero_items() {
3216 let mut state = VirtualizedListState::new();
3217 state.scroll(10, 0);
3218 assert_eq!(state.scroll_offset(), 0);
3219 }
3220
3221 #[test]
3222 fn list_state_scroll_to_clamps() {
3223 let mut state = VirtualizedListState::new();
3224 state.scroll_to(100, 10);
3225 assert_eq!(state.scroll_offset(), 9);
3226 }
3227
3228 #[test]
3229 fn list_state_scroll_to_bottom_zero_items() {
3230 let mut state = VirtualizedListState::new();
3231 state.scroll_to_bottom(0);
3232 assert_eq!(state.scroll_offset(), 0);
3233 }
3234
3235 #[test]
3236 fn list_state_is_at_bottom_zero_items() {
3237 let state = VirtualizedListState::new();
3238 assert!(state.is_at_bottom(0));
3239 }
3240
3241 #[test]
3242 fn list_state_page_up_visible_count_zero() {
3243 let mut state = VirtualizedListState::new();
3244 state.scroll_offset = 5;
3245 state.page_up(20);
3246 assert_eq!(state.scroll_offset(), 5);
3248 }
3249
3250 #[test]
3251 fn list_state_page_down_visible_count_zero() {
3252 let mut state = VirtualizedListState::new();
3253 state.page_down(20);
3254 assert_eq!(state.scroll_offset(), 0);
3256 }
3257
3258 #[test]
3259 fn list_state_set_follow_false_no_scroll() {
3260 let mut state = VirtualizedListState::new();
3261 state.scroll_offset = 5;
3262 state.set_follow(false, 20);
3263 assert_eq!(state.scroll_offset(), 5); assert!(!state.follow_mode());
3265 }
3266
3267 #[test]
3268 fn list_state_persistence_id() {
3269 let state = VirtualizedListState::new().with_persistence_id("my-list");
3270 assert_eq!(state.persistence_id(), Some("my-list"));
3271 }
3272
3273 #[test]
3274 fn list_state_persistence_id_none() {
3275 let state = VirtualizedListState::new();
3276 assert_eq!(state.persistence_id(), None);
3277 }
3278
3279 #[test]
3280 fn list_state_momentum_tick_zero_items() {
3281 let mut state = VirtualizedListState::new();
3282 state.fling(50.0);
3283 state.tick(Duration::from_millis(100), 0);
3284 assert_eq!(state.scroll_offset(), 0);
3286 }
3287
3288 #[test]
3291 fn persist_state_default() {
3292 let ps = VirtualizedListPersistState::default();
3293 assert_eq!(ps.selected, None);
3294 assert_eq!(ps.scroll_offset, 0);
3295 assert!(!ps.follow_mode);
3296 }
3297
3298 #[test]
3299 fn persist_state_eq() {
3300 let a = VirtualizedListPersistState {
3301 selected: Some(5),
3302 scroll_offset: 10,
3303 follow_mode: true,
3304 };
3305 let b = a.clone();
3306 assert_eq!(a, b);
3307 }
3308
3309 #[test]
3312 fn stateful_state_key_with_persistence_id() {
3313 use crate::stateful::Stateful;
3314 let state = VirtualizedListState::new().with_persistence_id("logs");
3315 let key = state.state_key();
3316 assert_eq!(key.widget_type, "VirtualizedList");
3317 assert_eq!(key.instance_id, "logs");
3318 }
3319
3320 #[test]
3321 fn stateful_state_key_default_instance() {
3322 use crate::stateful::Stateful;
3323 let state = VirtualizedListState::new();
3324 let key = state.state_key();
3325 assert_eq!(key.instance_id, "default");
3326 }
3327
3328 #[test]
3329 fn stateful_save_restore_roundtrip() {
3330 use crate::stateful::Stateful;
3331 let mut state = VirtualizedListState::new();
3332 state.selected = Some(7);
3333 state.scroll_offset = 15;
3334 state.follow_mode = true;
3335 state.scroll_velocity = 42.0; let saved = state.save_state();
3338 assert_eq!(saved.selected, Some(7));
3339 assert_eq!(saved.scroll_offset, 15);
3340 assert!(saved.follow_mode);
3341
3342 let mut restored = VirtualizedListState::new();
3343 restored.scroll_velocity = 99.0;
3344 restored.restore_state(saved);
3345 assert_eq!(restored.selected, Some(7));
3346 assert_eq!(restored.scroll_offset, 15);
3347 assert!(restored.follow_mode);
3348 assert_eq!(restored.scroll_velocity, 0.0);
3350 }
3351
3352 #[test]
3355 fn virtualized_list_builder() {
3356 let items: Vec<String> = vec!["a".into()];
3357 let list = VirtualizedList::new(&items)
3358 .style(Style::default())
3359 .highlight_style(Style::default())
3360 .show_scrollbar(false)
3361 .fixed_height(3);
3362 assert_eq!(list.fixed_height, 3);
3363 assert!(!list.show_scrollbar);
3364 }
3365
3366 #[test]
3369 fn virtualized_storage_debug() {
3370 let storage: VirtualizedStorage<i32> = VirtualizedStorage::Owned(VecDeque::new());
3371 let dbg = format!("{:?}", storage);
3372 assert!(dbg.contains("Owned"));
3373
3374 let ext: VirtualizedStorage<i32> = VirtualizedStorage::External {
3375 len: 100,
3376 cache_capacity: 10,
3377 };
3378 let dbg = format!("{:?}", ext);
3379 assert!(dbg.contains("External"));
3380 }
3381
3382 #[test]
3383 fn test_virtualized_list_handle_mouse_drag_smooth() {
3384 use crate::scrollbar::SCROLLBAR_PART_THUMB;
3385 use ftui_core::event::{MouseButton, MouseEvent, MouseEventKind};
3386 use ftui_render::frame::{HitId, HitRegion};
3387
3388 let mut state = VirtualizedListState::new();
3389 let scrollbar_hit_id = HitId::new(1);
3390 let total_items = 100;
3391 let viewport_height = 10;
3392 let fixed_height = 1;
3393
3394 let track_len = 10u64;
3400 let track_pos = 0u64;
3401 let hit_data = (SCROLLBAR_PART_THUMB << 56)
3402 | ((track_len & 0x0FFF_FFFF) << 28)
3403 | (track_pos & 0x0FFF_FFFF);
3404
3405 let down_event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 0, 0);
3406 let hit = Some((scrollbar_hit_id, HitRegion::Scrollbar, hit_data));
3407
3408 state.handle_mouse(
3409 &down_event,
3410 hit,
3411 scrollbar_hit_id,
3412 total_items,
3413 viewport_height,
3414 fixed_height,
3415 );
3416
3417 assert!(
3418 state.scrollbar_drag_anchor.is_some(),
3419 "Drag anchor should be set on down"
3420 );
3421 assert_eq!(
3422 state.scrollbar_drag_anchor.unwrap(),
3423 0,
3424 "Anchor should be 0 (clicked top of thumb)"
3425 );
3426
3427 let drag_pos = 1u64;
3432 let drag_data = (SCROLLBAR_PART_THUMB << 56)
3433 | ((track_len & 0x0FFF_FFFF) << 28)
3434 | (drag_pos & 0x0FFF_FFFF);
3435 let drag_event = MouseEvent::new(MouseEventKind::Drag(MouseButton::Left), 0, 1);
3436 let drag_hit = Some((scrollbar_hit_id, HitRegion::Scrollbar, drag_data));
3437
3438 state.handle_mouse(
3439 &drag_event,
3440 drag_hit,
3441 scrollbar_hit_id,
3442 total_items,
3443 viewport_height,
3444 fixed_height,
3445 );
3446
3447 assert_eq!(
3448 state.scroll_offset, 10,
3449 "Scroll offset should update smoothly"
3450 );
3451 }
3452}