Skip to main content

ftui_widgets/
virtualized.rs

1#![forbid(unsafe_code)]
2
3//! Virtualization primitives for efficient rendering of large content.
4//!
5//! This module provides the foundational types for rendering only visible
6//! portions of large datasets, enabling smooth performance with 100K+ items.
7//!
8//! # Core Types
9//!
10//! - [`Virtualized<T>`] - Generic container with visible range calculation
11//! - [`VirtualizedStorage`] - Owned vs external storage abstraction
12//! - [`ItemHeight`] - Fixed vs variable height support
13//! - [`HeightCache`] - LRU cache for measured item heights
14//!
15//! # Example
16//!
17//! ```ignore
18//! use ftui_widgets::virtualized::{Virtualized, ItemHeight};
19//!
20//! // Create with owned storage
21//! let mut virt: Virtualized<String> = Virtualized::new(10_000);
22//!
23//! // Add items
24//! for i in 0..1000 {
25//!     virt.push(format!("Line {}", i));
26//! }
27//!
28//! // Get visible range for viewport height
29//! let range = virt.visible_range(24);
30//! println!("Visible: {}..{}", range.start, range.end);
31//! ```
32
33use std::cell::Cell as StdCell;
34use std::collections::VecDeque;
35use std::ops::Range;
36use std::time::Duration;
37
38// Imports for future rendering support (currently unused but planned)
39#[allow(unused_imports)]
40use crate::scrollbar::{Scrollbar, ScrollbarOrientation, ScrollbarState};
41#[allow(unused_imports)]
42use crate::{StatefulWidget, set_style_area};
43#[allow(unused_imports)]
44use ftui_core::geometry::Rect;
45#[allow(unused_imports)]
46use ftui_render::cell::Cell;
47#[allow(unused_imports)]
48use ftui_render::frame::Frame;
49#[allow(unused_imports)]
50use ftui_style::Style;
51
52/// A virtualized content container that tracks scroll state and computes visible ranges.
53///
54/// # Design Rationale
55/// - Generic over item type for flexibility
56/// - Supports both owned storage and external data sources
57/// - Computes visible ranges for O(visible) rendering
58/// - Optional overscan for smooth scrolling
59/// - Momentum scrolling support
60#[derive(Debug, Clone)]
61pub struct Virtualized<T> {
62    /// The stored items (or external storage reference).
63    storage: VirtualizedStorage<T>,
64    /// Current scroll offset (in items).
65    scroll_offset: usize,
66    /// Number of visible items (cached from last render).
67    visible_count: StdCell<usize>,
68    /// Overscan: extra items rendered above/below visible.
69    overscan: usize,
70    /// Height calculation strategy.
71    item_height: ItemHeight,
72    /// Whether to auto-scroll on new items.
73    follow_mode: bool,
74    /// Scroll velocity for momentum scrolling.
75    scroll_velocity: f32,
76}
77
78/// Storage strategy for virtualized items.
79#[derive(Debug, Clone)]
80pub enum VirtualizedStorage<T> {
81    /// Owned vector of items.
82    Owned(VecDeque<T>),
83    /// External storage with known length.
84    /// Note: External fetch is handled at the widget level.
85    External {
86        /// Total number of items available.
87        len: usize,
88        /// Maximum items to keep in local cache.
89        cache_capacity: usize,
90    },
91}
92
93/// Height calculation strategy for items.
94#[derive(Debug, Clone)]
95pub enum ItemHeight {
96    /// All items have fixed height.
97    Fixed(u16),
98    /// Items have variable height, cached lazily (linear scan).
99    Variable(HeightCache),
100    /// Items have variable height with O(log n) scroll-to-index via Fenwick tree.
101    VariableFenwick(VariableHeightsFenwick),
102}
103
104/// LRU cache for measured item heights.
105#[derive(Debug, Clone)]
106pub struct HeightCache {
107    /// Height measurements indexed by (item index - base_offset).
108    cache: Vec<Option<u16>>,
109    /// Offset of the first entry in the cache (cache[0] corresponds to this item index).
110    base_offset: usize,
111    /// Default height for unmeasured items.
112    default_height: u16,
113    /// Maximum entries to cache (for memory bounds).
114    capacity: usize,
115}
116
117impl<T> Virtualized<T> {
118    /// Create a new virtualized container with owned storage.
119    ///
120    /// # Arguments
121    /// * `capacity` - Maximum items to retain in memory.
122    #[must_use]
123    pub fn new(capacity: usize) -> Self {
124        Self {
125            storage: VirtualizedStorage::Owned(VecDeque::with_capacity(capacity.min(1024))),
126            scroll_offset: 0,
127            visible_count: StdCell::new(0),
128            overscan: 2,
129            item_height: ItemHeight::Fixed(1),
130            follow_mode: false,
131            scroll_velocity: 0.0,
132        }
133    }
134
135    /// Create with external storage reference.
136    #[must_use]
137    pub fn external(len: usize, cache_capacity: usize) -> Self {
138        Self {
139            storage: VirtualizedStorage::External {
140                len,
141                cache_capacity,
142            },
143            scroll_offset: 0,
144            visible_count: StdCell::new(0),
145            overscan: 2,
146            item_height: ItemHeight::Fixed(1),
147            follow_mode: false,
148            scroll_velocity: 0.0,
149        }
150    }
151
152    /// Set item height strategy.
153    #[must_use]
154    pub fn with_item_height(mut self, height: ItemHeight) -> Self {
155        self.item_height = height;
156        self
157    }
158
159    /// Set fixed item height.
160    #[must_use]
161    pub fn with_fixed_height(mut self, height: u16) -> Self {
162        self.item_height = ItemHeight::Fixed(height);
163        self
164    }
165
166    /// Set variable heights with O(log n) Fenwick tree tracking.
167    ///
168    /// This is more efficient than `Variable(HeightCache)` for large lists
169    /// as scroll-to-index mapping is O(log n) instead of O(visible).
170    #[must_use]
171    pub fn with_variable_heights_fenwick(mut self, default_height: u16, capacity: usize) -> Self {
172        self.item_height =
173            ItemHeight::VariableFenwick(VariableHeightsFenwick::new(default_height, capacity));
174        self
175    }
176
177    /// Set overscan amount.
178    #[must_use]
179    pub fn with_overscan(mut self, overscan: usize) -> Self {
180        self.overscan = overscan;
181        self
182    }
183
184    /// Enable follow mode.
185    #[must_use]
186    pub fn with_follow(mut self, follow: bool) -> Self {
187        self.follow_mode = follow;
188        self
189    }
190
191    /// Get total number of items.
192    #[must_use]
193    pub fn len(&self) -> usize {
194        match &self.storage {
195            VirtualizedStorage::Owned(items) => items.len(),
196            VirtualizedStorage::External { len, .. } => *len,
197        }
198    }
199
200    /// Check if empty.
201    #[must_use]
202    pub fn is_empty(&self) -> bool {
203        self.len() == 0
204    }
205
206    /// Get current scroll offset.
207    #[must_use]
208    pub fn scroll_offset(&self) -> usize {
209        self.scroll_offset
210    }
211
212    /// Get current visible count (from last render).
213    #[must_use]
214    pub fn visible_count(&self) -> usize {
215        self.visible_count.get()
216    }
217
218    /// Check if follow mode is enabled.
219    #[must_use]
220    pub fn follow_mode(&self) -> bool {
221        self.follow_mode
222    }
223
224    /// Calculate visible range for given viewport height.
225    #[must_use]
226    pub fn visible_range(&self, viewport_height: u16) -> Range<usize> {
227        if self.is_empty() || viewport_height == 0 {
228            self.visible_count.set(0);
229            return 0..0;
230        }
231
232        let items_visible = match &self.item_height {
233            ItemHeight::Fixed(h) if *h > 0 => (viewport_height / h) as usize,
234            ItemHeight::Fixed(_) => viewport_height as usize,
235            ItemHeight::Variable(cache) => {
236                // Sum heights until the next item would exceed viewport (O(visible))
237                let mut count = 0;
238                let mut total_height = 0u16;
239                let start = self.scroll_offset;
240                while start + count < self.len() {
241                    let next = cache.get(start + count);
242                    let proposed = total_height.saturating_add(next);
243                    if proposed > viewport_height {
244                        break;
245                    }
246                    total_height = proposed;
247                    count += 1;
248                }
249                count
250            }
251            ItemHeight::VariableFenwick(tracker) => {
252                // O(log n) using Fenwick tree
253                tracker.visible_count(self.scroll_offset, viewport_height)
254            }
255        };
256
257        let start = self.scroll_offset;
258        let end = (start + items_visible).min(self.len());
259        self.visible_count.set(items_visible);
260        start..end
261    }
262
263    /// Get render range with overscan for smooth scrolling.
264    #[must_use]
265    pub fn render_range(&self, viewport_height: u16) -> Range<usize> {
266        let visible = self.visible_range(viewport_height);
267        let start = visible.start.saturating_sub(self.overscan);
268        let end = visible.end.saturating_add(self.overscan).min(self.len());
269        start..end
270    }
271
272    /// Scroll by delta (positive = down/forward).
273    pub fn scroll(&mut self, delta: i32) {
274        if self.is_empty() {
275            return;
276        }
277        let visible_count = self.visible_count.get();
278        let max_offset = if visible_count > 0 {
279            self.len().saturating_sub(visible_count)
280        } else {
281            self.len().saturating_sub(1)
282        };
283        let new_offset = (self.scroll_offset as i64 + delta as i64)
284            .max(0)
285            .min(max_offset as i64);
286        self.scroll_offset = new_offset as usize;
287
288        // Disable follow mode on manual scroll
289        if delta != 0 {
290            self.follow_mode = false;
291        }
292    }
293
294    /// Scroll to specific item index.
295    pub fn scroll_to(&mut self, idx: usize) {
296        self.scroll_offset = idx.min(self.len().saturating_sub(1));
297        self.follow_mode = false;
298    }
299
300    /// Scroll to bottom.
301    pub fn scroll_to_bottom(&mut self) {
302        let visible_count = self.visible_count.get();
303        if self.len() > visible_count && visible_count > 0 {
304            self.scroll_offset = self.len() - visible_count;
305        } else {
306            self.scroll_offset = 0;
307        }
308    }
309
310    /// Scroll to top.
311    pub fn scroll_to_top(&mut self) {
312        self.scroll_offset = 0;
313        self.follow_mode = false;
314    }
315
316    /// Alias for scroll_to_top (Home key).
317    pub fn scroll_to_start(&mut self) {
318        self.scroll_to_top();
319    }
320
321    /// Scroll to bottom and enable follow mode (End key).
322    pub fn scroll_to_end(&mut self) {
323        self.scroll_to_bottom();
324        self.follow_mode = true;
325    }
326
327    /// Page up (scroll by visible count).
328    pub fn page_up(&mut self) {
329        let visible_count = self.visible_count.get();
330        if visible_count > 0 {
331            self.scroll(-(visible_count as i32));
332        }
333    }
334
335    /// Page down (scroll by visible count).
336    pub fn page_down(&mut self) {
337        let visible_count = self.visible_count.get();
338        if visible_count > 0 {
339            self.scroll(visible_count as i32);
340        }
341    }
342
343    /// Set follow mode.
344    pub fn set_follow(&mut self, follow: bool) {
345        self.follow_mode = follow;
346        if follow {
347            self.scroll_to_bottom();
348        }
349    }
350
351    /// Check if scrolled to bottom.
352    #[must_use]
353    pub fn is_at_bottom(&self) -> bool {
354        let visible_count = self.visible_count.get();
355        if self.len() <= visible_count {
356            true
357        } else {
358            self.scroll_offset >= self.len() - visible_count
359        }
360    }
361
362    /// Start momentum scroll.
363    pub fn fling(&mut self, velocity: f32) {
364        self.scroll_velocity = velocity;
365    }
366
367    /// Apply momentum scroll tick.
368    pub fn tick(&mut self, dt: Duration) {
369        if self.scroll_velocity.abs() > 0.1 {
370            let delta = (self.scroll_velocity * dt.as_secs_f32()) as i32;
371            if delta != 0 {
372                self.scroll(delta);
373            }
374            // Apply friction
375            self.scroll_velocity *= 0.95;
376        } else {
377            self.scroll_velocity = 0.0;
378        }
379    }
380
381    /// Update visible count (called during render).
382    pub fn set_visible_count(&mut self, count: usize) {
383        self.visible_count.set(count);
384    }
385}
386
387impl<T> Virtualized<T> {
388    /// Push an item (owned storage only).
389    pub fn push(&mut self, item: T) {
390        if let VirtualizedStorage::Owned(items) = &mut self.storage {
391            items.push_back(item);
392            if self.follow_mode {
393                self.scroll_to_bottom();
394            }
395        }
396    }
397
398    /// Get item by index (owned storage only).
399    #[must_use]
400    pub fn get(&self, idx: usize) -> Option<&T> {
401        if let VirtualizedStorage::Owned(items) = &self.storage {
402            items.get(idx)
403        } else {
404            None
405        }
406    }
407
408    /// Get mutable item by index (owned storage only).
409    pub fn get_mut(&mut self, idx: usize) -> Option<&mut T> {
410        if let VirtualizedStorage::Owned(items) = &mut self.storage {
411            items.get_mut(idx)
412        } else {
413            None
414        }
415    }
416
417    /// Clear all items (owned storage only).
418    pub fn clear(&mut self) {
419        if let VirtualizedStorage::Owned(items) = &mut self.storage {
420            items.clear();
421        }
422        self.scroll_offset = 0;
423    }
424
425    /// Trim items from the front to keep at most `max` items (owned storage only).
426    ///
427    /// Returns the number of items removed.
428    pub fn trim_front(&mut self, max: usize) -> usize {
429        if let VirtualizedStorage::Owned(items) = &mut self.storage
430            && items.len() > max
431        {
432            let to_remove = items.len() - max;
433            items.drain(..to_remove);
434            // Adjust scroll_offset if it was pointing beyond the new start
435            self.scroll_offset = self.scroll_offset.saturating_sub(to_remove);
436            return to_remove;
437        }
438        0
439    }
440
441    /// Iterate over items (owned storage only).
442    /// Returns empty iterator for external storage.
443    pub fn iter(&self) -> Box<dyn Iterator<Item = &T> + '_> {
444        match &self.storage {
445            VirtualizedStorage::Owned(items) => Box::new(items.iter()),
446            VirtualizedStorage::External { .. } => Box::new(std::iter::empty()),
447        }
448    }
449
450    /// Update external storage length.
451    pub fn set_external_len(&mut self, len: usize) {
452        if let VirtualizedStorage::External { len: l, .. } = &mut self.storage {
453            *l = len;
454            if self.follow_mode {
455                self.scroll_to_bottom();
456            }
457        }
458    }
459}
460
461impl Default for HeightCache {
462    fn default() -> Self {
463        Self::new(1, 1000)
464    }
465}
466
467impl HeightCache {
468    /// Create a new height cache.
469    #[must_use]
470    pub fn new(default_height: u16, capacity: usize) -> Self {
471        Self {
472            cache: Vec::new(),
473            base_offset: 0,
474            default_height,
475            capacity,
476        }
477    }
478
479    /// Get height for item, returning default if not cached.
480    #[must_use]
481    pub fn get(&self, idx: usize) -> u16 {
482        if idx < self.base_offset {
483            return self.default_height;
484        }
485        let local = idx - self.base_offset;
486        self.cache
487            .get(local)
488            .and_then(|h| *h)
489            .unwrap_or(self.default_height)
490    }
491
492    /// Set height for item.
493    pub fn set(&mut self, idx: usize, height: u16) {
494        if self.capacity == 0 {
495            return;
496        }
497        if idx < self.base_offset {
498            // Index has been trimmed away; ignore
499            return;
500        }
501        let mut local = idx - self.base_offset;
502        if local >= self.capacity {
503            // Large index jump: reset window to avoid huge allocations.
504            self.base_offset = idx.saturating_add(1).saturating_sub(self.capacity);
505            self.cache.clear();
506            local = idx - self.base_offset;
507        }
508        if local >= self.cache.len() {
509            self.cache.resize(local + 1, None);
510        }
511        self.cache[local] = Some(height);
512
513        // Trim if over capacity: remove oldest entries and adjust base_offset
514        if self.cache.len() > self.capacity {
515            let to_remove = self.cache.len() - self.capacity;
516            self.cache.drain(0..to_remove);
517            self.base_offset += to_remove;
518        }
519    }
520
521    /// Clear cached heights.
522    pub fn clear(&mut self) {
523        self.cache.clear();
524        self.base_offset = 0;
525    }
526}
527
528// ============================================================================
529// VariableHeightsFenwick - O(log n) scroll-to-index mapping
530// ============================================================================
531
532use crate::fenwick::FenwickTree;
533
534/// Variable height tracker using Fenwick tree for O(log n) prefix sum queries.
535///
536/// This enables efficient scroll offset to item index mapping for virtualized
537/// lists with variable height items.
538///
539/// # Operations
540///
541/// | Operation | Time |
542/// |-----------|------|
543/// | `find_item_at_offset` | O(log n) |
544/// | `offset_of_item` | O(log n) |
545/// | `set_height` | O(log n) |
546/// | `total_height` | O(log n) |
547///
548/// # Invariants
549///
550/// 1. `tree.prefix(i)` == sum of heights [0..=i]
551/// 2. `find_item_at_offset(offset)` returns largest i where prefix(i-1) < offset
552/// 3. Heights are u32 internally (u16 input widened for large lists)
553#[derive(Debug, Clone)]
554pub struct VariableHeightsFenwick {
555    /// Fenwick tree storing item heights.
556    tree: FenwickTree,
557    /// Default height for items not yet measured.
558    default_height: u16,
559    /// Number of items tracked.
560    len: usize,
561}
562
563impl Default for VariableHeightsFenwick {
564    fn default() -> Self {
565        Self::new(1, 0)
566    }
567}
568
569impl VariableHeightsFenwick {
570    /// Create a new height tracker with given default height and initial capacity.
571    #[must_use]
572    pub fn new(default_height: u16, capacity: usize) -> Self {
573        let tree = if capacity > 0 {
574            // Initialize with default heights
575            let heights: Vec<u32> = vec![u32::from(default_height); capacity];
576            FenwickTree::from_values(&heights)
577        } else {
578            FenwickTree::new(0)
579        };
580        Self {
581            tree,
582            default_height,
583            len: capacity,
584        }
585    }
586
587    /// Create from a slice of heights.
588    #[must_use]
589    pub fn from_heights(heights: &[u16], default_height: u16) -> Self {
590        let heights_u32: Vec<u32> = heights.iter().map(|&h| u32::from(h)).collect();
591        Self {
592            tree: FenwickTree::from_values(&heights_u32),
593            default_height,
594            len: heights.len(),
595        }
596    }
597
598    /// Number of items tracked.
599    #[must_use]
600    pub fn len(&self) -> usize {
601        self.len
602    }
603
604    /// Whether tracking is empty.
605    #[must_use]
606    pub fn is_empty(&self) -> bool {
607        self.len == 0
608    }
609
610    /// Get the default height for unmeasured items.
611    #[must_use]
612    pub fn default_height(&self) -> u16 {
613        self.default_height
614    }
615
616    /// Get height of a specific item. O(log n).
617    #[must_use]
618    pub fn get(&self, idx: usize) -> u16 {
619        if idx >= self.len {
620            return self.default_height;
621        }
622        // Fenwick get returns the individual value at idx
623        self.tree.get(idx).min(u32::from(u16::MAX)) as u16
624    }
625
626    /// Set height of a specific item. O(log n).
627    pub fn set(&mut self, idx: usize, height: u16) {
628        if idx >= self.len {
629            // Need to resize
630            self.resize(idx + 1);
631        }
632        self.tree.set(idx, u32::from(height));
633    }
634
635    /// Get the y-offset (in pixels/rows) of an item. O(log n).
636    ///
637    /// Returns the sum of heights of all items before `idx`.
638    #[must_use]
639    pub fn offset_of_item(&self, idx: usize) -> u32 {
640        if idx == 0 || self.len == 0 {
641            return 0;
642        }
643        let clamped = idx.min(self.len);
644        if clamped > 0 {
645            self.tree.prefix(clamped - 1)
646        } else {
647            0
648        }
649    }
650
651    /// Find the item index at a given scroll offset. O(log n).
652    ///
653    /// Returns the index of the item that occupies the given offset.
654    /// If offset is beyond all items, returns `self.len`.
655    ///
656    /// Item i occupies offsets [offset_of_item(i), offset_of_item(i+1)).
657    #[must_use]
658    pub fn find_item_at_offset(&self, offset: u32) -> usize {
659        if self.len == 0 {
660            return 0;
661        }
662        if offset == 0 {
663            return 0;
664        }
665        // find_prefix returns largest i where prefix(i) <= offset
666        // prefix(i) = sum of heights [0..=i] = y-coordinate just past item i
667        // If prefix(i) <= offset, then offset is at or past the end of item i,
668        // so offset is in item i+1.
669        //
670        // We use offset - 1 to check: if prefix(i) <= offset - 1, then offset > prefix(i),
671        // meaning we're strictly past item i.
672        // But we also need to handle the case where offset == prefix(i) exactly
673        // (offset is first row of item i+1).
674        match self.tree.find_prefix(offset) {
675            Some(i) => {
676                // prefix(i) <= offset
677                // Item i spans [prefix(i-1), prefix(i)), so offset >= prefix(i)
678                // means offset is in item i+1 or beyond
679                (i + 1).min(self.len)
680            }
681            None => {
682                // offset < prefix(0), so offset is within item 0
683                0
684            }
685        }
686    }
687
688    /// Count how many items fit within a viewport starting at `start_idx`. O(log n).
689    ///
690    /// Returns the number of items that fit completely within `viewport_height`.
691    #[must_use]
692    pub fn visible_count(&self, start_idx: usize, viewport_height: u16) -> usize {
693        if self.len == 0 || viewport_height == 0 {
694            return 0;
695        }
696        let start = start_idx.min(self.len);
697        let start_offset = self.offset_of_item(start);
698        let end_offset = start_offset + u32::from(viewport_height);
699
700        // Find last item that fits
701        let end_idx = self.find_item_at_offset(end_offset);
702
703        // Count items from start to end (exclusive of partially visible)
704        if end_idx > start {
705            // Check if end_idx item is fully visible
706            let end_item_start = self.offset_of_item(end_idx);
707            if end_item_start + u32::from(self.get(end_idx)) <= end_offset {
708                end_idx - start + 1
709            } else {
710                end_idx - start
711            }
712        } else {
713            // At least show one item if viewport has space
714            if viewport_height > 0 && start < self.len {
715                1
716            } else {
717                0
718            }
719        }
720    }
721
722    /// Get total height of all items. O(log n).
723    #[must_use]
724    pub fn total_height(&self) -> u32 {
725        self.tree.total()
726    }
727
728    /// Resize the tracker to accommodate `new_len` items.
729    ///
730    /// New items are initialized with default height.
731    pub fn resize(&mut self, new_len: usize) {
732        if new_len == self.len {
733            return;
734        }
735        self.tree.resize(new_len);
736        // Set default heights for new items
737        if new_len > self.len {
738            for i in self.len..new_len {
739                self.tree.set(i, u32::from(self.default_height));
740            }
741        }
742        self.len = new_len;
743    }
744
745    /// Clear all height data.
746    pub fn clear(&mut self) {
747        self.tree = FenwickTree::new(0);
748        self.len = 0;
749    }
750
751    /// Rebuild from a fresh set of heights.
752    pub fn rebuild(&mut self, heights: &[u16]) {
753        let heights_u32: Vec<u32> = heights.iter().map(|&h| u32::from(h)).collect();
754        self.tree = FenwickTree::from_values(&heights_u32);
755        self.len = heights.len();
756    }
757}
758
759// ============================================================================
760// VirtualizedList Widget
761// ============================================================================
762
763/// Trait for items that can render themselves.
764///
765/// Implement this trait for item types that should render in a `VirtualizedList`.
766pub trait RenderItem {
767    /// Render the item into the frame at the given area.
768    fn render(&self, area: Rect, frame: &mut Frame, selected: bool);
769
770    /// Height of this item in terminal rows.
771    fn height(&self) -> u16 {
772        1
773    }
774}
775
776/// State for the VirtualizedList widget.
777#[derive(Debug, Clone)]
778pub struct VirtualizedListState {
779    /// Currently selected index.
780    pub selected: Option<usize>,
781    /// Scroll offset.
782    scroll_offset: usize,
783    /// Visible count (from last render).
784    visible_count: usize,
785    /// Overscan amount.
786    overscan: usize,
787    /// Whether follow mode is enabled.
788    follow_mode: bool,
789    /// Scroll velocity for momentum.
790    scroll_velocity: f32,
791    /// Optional persistence ID for state saving/restoration.
792    persistence_id: Option<String>,
793}
794
795impl Default for VirtualizedListState {
796    fn default() -> Self {
797        Self::new()
798    }
799}
800
801impl VirtualizedListState {
802    /// Create a new state.
803    #[must_use]
804    pub fn new() -> Self {
805        Self {
806            selected: None,
807            scroll_offset: 0,
808            visible_count: 0,
809            overscan: 2,
810            follow_mode: false,
811            scroll_velocity: 0.0,
812            persistence_id: None,
813        }
814    }
815
816    /// Create with overscan.
817    #[must_use]
818    pub fn with_overscan(mut self, overscan: usize) -> Self {
819        self.overscan = overscan;
820        self
821    }
822
823    /// Create with follow mode enabled.
824    #[must_use]
825    pub fn with_follow(mut self, follow: bool) -> Self {
826        self.follow_mode = follow;
827        self
828    }
829
830    /// Create with a persistence ID for state saving.
831    #[must_use]
832    pub fn with_persistence_id(mut self, id: impl Into<String>) -> Self {
833        self.persistence_id = Some(id.into());
834        self
835    }
836
837    /// Get the persistence ID, if set.
838    #[must_use]
839    pub fn persistence_id(&self) -> Option<&str> {
840        self.persistence_id.as_deref()
841    }
842
843    /// Get current scroll offset.
844    #[must_use]
845    pub fn scroll_offset(&self) -> usize {
846        self.scroll_offset
847    }
848
849    /// Get visible item count (from last render).
850    #[must_use]
851    pub fn visible_count(&self) -> usize {
852        self.visible_count
853    }
854
855    /// Scroll by delta (positive = down).
856    pub fn scroll(&mut self, delta: i32, total_items: usize) {
857        if total_items == 0 {
858            return;
859        }
860        let max_offset = if self.visible_count > 0 {
861            total_items.saturating_sub(self.visible_count)
862        } else {
863            total_items.saturating_sub(1)
864        };
865        let new_offset = (self.scroll_offset as i64 + delta as i64)
866            .max(0)
867            .min(max_offset as i64);
868        self.scroll_offset = new_offset as usize;
869
870        if delta != 0 {
871            self.follow_mode = false;
872        }
873    }
874
875    /// Scroll to specific index.
876    pub fn scroll_to(&mut self, idx: usize, total_items: usize) {
877        self.scroll_offset = idx.min(total_items.saturating_sub(1));
878        self.follow_mode = false;
879    }
880
881    /// Scroll to top.
882    pub fn scroll_to_top(&mut self) {
883        self.scroll_offset = 0;
884        self.follow_mode = false;
885    }
886
887    /// Scroll to bottom.
888    pub fn scroll_to_bottom(&mut self, total_items: usize) {
889        if total_items > self.visible_count && self.visible_count > 0 {
890            self.scroll_offset = total_items - self.visible_count;
891        } else {
892            self.scroll_offset = 0;
893        }
894    }
895
896    /// Page up (scroll by visible count).
897    pub fn page_up(&mut self, total_items: usize) {
898        if self.visible_count > 0 {
899            self.scroll(-(self.visible_count as i32), total_items);
900        }
901    }
902
903    /// Page down (scroll by visible count).
904    pub fn page_down(&mut self, total_items: usize) {
905        if self.visible_count > 0 {
906            self.scroll(self.visible_count as i32, total_items);
907        }
908    }
909
910    /// Select an item.
911    pub fn select(&mut self, index: Option<usize>) {
912        self.selected = index;
913    }
914
915    /// Select previous item.
916    pub fn select_previous(&mut self, total_items: usize) {
917        if total_items == 0 {
918            self.selected = None;
919            return;
920        }
921        self.selected = Some(match self.selected {
922            Some(i) if i > 0 => i - 1,
923            Some(_) => 0,
924            None => 0,
925        });
926    }
927
928    /// Select next item.
929    pub fn select_next(&mut self, total_items: usize) {
930        if total_items == 0 {
931            self.selected = None;
932            return;
933        }
934        self.selected = Some(match self.selected {
935            Some(i) if i < total_items - 1 => i + 1,
936            Some(i) => i,
937            None => 0,
938        });
939    }
940
941    /// Check if at bottom.
942    #[must_use]
943    pub fn is_at_bottom(&self, total_items: usize) -> bool {
944        if total_items <= self.visible_count {
945            true
946        } else {
947            self.scroll_offset >= total_items - self.visible_count
948        }
949    }
950
951    /// Enable/disable follow mode.
952    pub fn set_follow(&mut self, follow: bool, total_items: usize) {
953        self.follow_mode = follow;
954        if follow {
955            self.scroll_to_bottom(total_items);
956        }
957    }
958
959    /// Check if follow mode is enabled.
960    #[must_use]
961    pub fn follow_mode(&self) -> bool {
962        self.follow_mode
963    }
964
965    /// Start momentum scroll.
966    pub fn fling(&mut self, velocity: f32) {
967        self.scroll_velocity = velocity;
968    }
969
970    /// Apply momentum scrolling tick.
971    pub fn tick(&mut self, dt: Duration, total_items: usize) {
972        if self.scroll_velocity.abs() > 0.1 {
973            let delta = (self.scroll_velocity * dt.as_secs_f32()) as i32;
974            if delta != 0 {
975                self.scroll(delta, total_items);
976            }
977            self.scroll_velocity *= 0.95;
978        } else {
979            self.scroll_velocity = 0.0;
980        }
981    }
982}
983
984// ============================================================================
985// Stateful Persistence Implementation for VirtualizedListState
986// ============================================================================
987
988/// Persistable state for a [`VirtualizedListState`].
989///
990/// Contains the user-facing scroll state that should survive sessions.
991/// Transient values like scroll_velocity and visible_count are not persisted.
992#[derive(Clone, Debug, Default, PartialEq)]
993#[cfg_attr(
994    feature = "state-persistence",
995    derive(serde::Serialize, serde::Deserialize)
996)]
997pub struct VirtualizedListPersistState {
998    /// Selected item index.
999    pub selected: Option<usize>,
1000    /// Scroll offset (first visible item).
1001    pub scroll_offset: usize,
1002    /// Whether follow mode is enabled.
1003    pub follow_mode: bool,
1004}
1005
1006impl crate::stateful::Stateful for VirtualizedListState {
1007    type State = VirtualizedListPersistState;
1008
1009    fn state_key(&self) -> crate::stateful::StateKey {
1010        crate::stateful::StateKey::new(
1011            "VirtualizedList",
1012            self.persistence_id.as_deref().unwrap_or("default"),
1013        )
1014    }
1015
1016    fn save_state(&self) -> VirtualizedListPersistState {
1017        VirtualizedListPersistState {
1018            selected: self.selected,
1019            scroll_offset: self.scroll_offset,
1020            follow_mode: self.follow_mode,
1021        }
1022    }
1023
1024    fn restore_state(&mut self, state: VirtualizedListPersistState) {
1025        self.selected = state.selected;
1026        self.scroll_offset = state.scroll_offset;
1027        self.follow_mode = state.follow_mode;
1028        // Reset transient values
1029        self.scroll_velocity = 0.0;
1030    }
1031}
1032
1033/// A virtualized list widget that renders only visible items.
1034///
1035/// This widget efficiently renders large lists by only drawing items
1036/// that are currently visible in the viewport, with optional overscan
1037/// for smooth scrolling.
1038#[derive(Debug)]
1039pub struct VirtualizedList<'a, T> {
1040    /// Items to render.
1041    items: &'a [T],
1042    /// Base style.
1043    style: Style,
1044    /// Style for selected item.
1045    highlight_style: Style,
1046    /// Whether to show scrollbar.
1047    show_scrollbar: bool,
1048    /// Fixed item height.
1049    fixed_height: u16,
1050}
1051
1052impl<'a, T> VirtualizedList<'a, T> {
1053    /// Create a new virtualized list.
1054    #[must_use]
1055    pub fn new(items: &'a [T]) -> Self {
1056        Self {
1057            items,
1058            style: Style::default(),
1059            highlight_style: Style::default(),
1060            show_scrollbar: true,
1061            fixed_height: 1,
1062        }
1063    }
1064
1065    /// Set base style.
1066    #[must_use]
1067    pub fn style(mut self, style: Style) -> Self {
1068        self.style = style;
1069        self
1070    }
1071
1072    /// Set highlight style for selected item.
1073    #[must_use]
1074    pub fn highlight_style(mut self, style: Style) -> Self {
1075        self.highlight_style = style;
1076        self
1077    }
1078
1079    /// Enable/disable scrollbar.
1080    #[must_use]
1081    pub fn show_scrollbar(mut self, show: bool) -> Self {
1082        self.show_scrollbar = show;
1083        self
1084    }
1085
1086    /// Set fixed item height.
1087    #[must_use]
1088    pub fn fixed_height(mut self, height: u16) -> Self {
1089        self.fixed_height = height;
1090        self
1091    }
1092}
1093
1094impl<T: RenderItem> StatefulWidget for VirtualizedList<'_, T> {
1095    type State = VirtualizedListState;
1096
1097    fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
1098        #[cfg(feature = "tracing")]
1099        let _span = tracing::debug_span!(
1100            "widget_render",
1101            widget = "VirtualizedList",
1102            x = area.x,
1103            y = area.y,
1104            w = area.width,
1105            h = area.height,
1106            items = self.items.len()
1107        )
1108        .entered();
1109
1110        if area.is_empty() {
1111            return;
1112        }
1113
1114        // Apply base style
1115        set_style_area(&mut frame.buffer, area, self.style);
1116
1117        let total_items = self.items.len();
1118        if total_items == 0 {
1119            return;
1120        }
1121
1122        // Reserve space for scrollbar if needed
1123        let items_per_viewport = (area.height / self.fixed_height.max(1)) as usize;
1124        let needs_scrollbar = self.show_scrollbar && total_items > items_per_viewport;
1125        let content_width = if needs_scrollbar {
1126            area.width.saturating_sub(1)
1127        } else {
1128            area.width
1129        };
1130
1131        // Ensure selection is within bounds
1132        if let Some(selected) = state.selected
1133            && selected >= total_items
1134        {
1135            // Use saturating_sub to handle empty list case (total_items = 0)
1136            state.selected = if total_items > 0 {
1137                Some(total_items - 1)
1138            } else {
1139                None
1140            };
1141        }
1142
1143        // Ensure visible range includes selected item
1144        if let Some(selected) = state.selected {
1145            if selected >= state.scroll_offset + items_per_viewport {
1146                state.scroll_offset = selected.saturating_sub(items_per_viewport.saturating_sub(1));
1147            } else if selected < state.scroll_offset {
1148                state.scroll_offset = selected;
1149            }
1150        }
1151
1152        // Clamp scroll offset
1153        let max_offset = total_items.saturating_sub(items_per_viewport);
1154        state.scroll_offset = state.scroll_offset.min(max_offset);
1155
1156        // Update visible count
1157        state.visible_count = items_per_viewport.min(total_items);
1158
1159        // Calculate render range with overscan
1160        let render_start = state.scroll_offset.saturating_sub(state.overscan);
1161        let render_end = state
1162            .scroll_offset
1163            .saturating_add(items_per_viewport)
1164            .saturating_add(state.overscan)
1165            .min(total_items);
1166
1167        // Render visible items
1168        for idx in render_start..render_end {
1169            // Calculate Y position relative to viewport
1170            let relative_idx = idx as i32 - state.scroll_offset as i32;
1171            let y_offset = relative_idx * self.fixed_height as i32;
1172
1173            // Skip items above viewport
1174            if y_offset + self.fixed_height as i32 <= 0 {
1175                continue;
1176            }
1177
1178            // Stop if below viewport
1179            if y_offset >= area.height as i32 {
1180                break;
1181            }
1182
1183            // Check if item starts off-screen top (terminal y < 0)
1184            // We cannot render at negative coordinates, and clamping to 0 causes artifacts
1185            // (drawing top of item instead of bottom). Skip such items.
1186            if i32::from(area.y) + y_offset < 0 {
1187                continue;
1188            }
1189
1190            // Calculate actual render area
1191            // Use i32 arithmetic to avoid overflow when casting y_offset to i16
1192            let y = i32::from(area.y)
1193                .saturating_add(y_offset)
1194                .clamp(0, i32::from(u16::MAX)) as u16;
1195            if y >= area.bottom() {
1196                break;
1197            }
1198
1199            let visible_height = self.fixed_height.min(area.bottom().saturating_sub(y));
1200            if visible_height == 0 {
1201                continue;
1202            }
1203
1204            let row_area = Rect::new(area.x, y, content_width, visible_height);
1205
1206            let is_selected = state.selected == Some(idx);
1207
1208            // Apply highlight style to selected row
1209            if is_selected {
1210                set_style_area(&mut frame.buffer, row_area, self.highlight_style);
1211            }
1212
1213            // Render the item
1214            self.items[idx].render(row_area, frame, is_selected);
1215        }
1216
1217        // Render scrollbar
1218        if needs_scrollbar {
1219            let scrollbar_area = Rect::new(area.right().saturating_sub(1), area.y, 1, area.height);
1220
1221            let mut scrollbar_state =
1222                ScrollbarState::new(total_items, state.scroll_offset, items_per_viewport);
1223
1224            let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight);
1225            scrollbar.render(scrollbar_area, frame, &mut scrollbar_state);
1226        }
1227    }
1228}
1229
1230// ============================================================================
1231// Simple RenderItem implementations for common types
1232// ============================================================================
1233
1234impl RenderItem for String {
1235    fn render(&self, area: Rect, frame: &mut Frame, _selected: bool) {
1236        if area.is_empty() {
1237            return;
1238        }
1239        let max_chars = area.width as usize;
1240        for (i, ch) in self.chars().take(max_chars).enumerate() {
1241            frame
1242                .buffer
1243                .set(area.x.saturating_add(i as u16), area.y, Cell::from_char(ch));
1244        }
1245    }
1246}
1247
1248impl RenderItem for &str {
1249    fn render(&self, area: Rect, frame: &mut Frame, _selected: bool) {
1250        if area.is_empty() {
1251            return;
1252        }
1253        let max_chars = area.width as usize;
1254        for (i, ch) in self.chars().take(max_chars).enumerate() {
1255            frame
1256                .buffer
1257                .set(area.x.saturating_add(i as u16), area.y, Cell::from_char(ch));
1258        }
1259    }
1260}
1261
1262#[cfg(test)]
1263mod tests {
1264    use super::*;
1265
1266    #[test]
1267    fn test_new_virtualized() {
1268        let virt: Virtualized<String> = Virtualized::new(100);
1269        assert_eq!(virt.len(), 0);
1270        assert!(virt.is_empty());
1271    }
1272
1273    #[test]
1274    fn test_push_and_len() {
1275        let mut virt: Virtualized<i32> = Virtualized::new(100);
1276        virt.push(1);
1277        virt.push(2);
1278        virt.push(3);
1279        assert_eq!(virt.len(), 3);
1280        assert!(!virt.is_empty());
1281    }
1282
1283    #[test]
1284    fn test_visible_range_fixed_height() {
1285        let mut virt: Virtualized<i32> = Virtualized::new(100).with_fixed_height(2);
1286        for i in 0..20 {
1287            virt.push(i);
1288        }
1289        // 10 items visible with height 2 in viewport 20
1290        let range = virt.visible_range(20);
1291        assert_eq!(range, 0..10);
1292    }
1293
1294    #[test]
1295    fn test_visible_range_variable_height_clamps() {
1296        let mut cache = HeightCache::new(1, 16);
1297        cache.set(0, 3);
1298        cache.set(1, 3);
1299        cache.set(2, 3);
1300        let mut virt: Virtualized<i32> =
1301            Virtualized::new(10).with_item_height(ItemHeight::Variable(cache));
1302        for i in 0..3 {
1303            virt.push(i);
1304        }
1305        let range = virt.visible_range(5);
1306        assert_eq!(range, 0..1);
1307    }
1308
1309    #[test]
1310    fn test_visible_range_variable_height_exact_fit() {
1311        let mut cache = HeightCache::new(1, 16);
1312        cache.set(0, 2);
1313        cache.set(1, 3);
1314        let mut virt: Virtualized<i32> =
1315            Virtualized::new(10).with_item_height(ItemHeight::Variable(cache));
1316        for i in 0..2 {
1317            virt.push(i);
1318        }
1319        let range = virt.visible_range(5);
1320        assert_eq!(range, 0..2);
1321    }
1322
1323    #[test]
1324    fn test_visible_range_with_scroll() {
1325        let mut virt: Virtualized<i32> = Virtualized::new(100).with_fixed_height(1);
1326        for i in 0..50 {
1327            virt.push(i);
1328        }
1329        virt.scroll(10);
1330        let range = virt.visible_range(10);
1331        assert_eq!(range, 10..20);
1332    }
1333
1334    #[test]
1335    fn test_visible_range_variable_height_excludes_partial() {
1336        let mut cache = HeightCache::new(1, 16);
1337        cache.set(0, 6);
1338        cache.set(1, 6);
1339        let mut virt: Virtualized<i32> =
1340            Virtualized::new(100).with_item_height(ItemHeight::Variable(cache));
1341        virt.push(1);
1342        virt.push(2);
1343        virt.push(3);
1344
1345        let range = virt.visible_range(10);
1346        assert_eq!(range, 0..1);
1347    }
1348
1349    #[test]
1350    fn test_visible_range_variable_height_exact_fit_larger() {
1351        let mut cache = HeightCache::new(1, 16);
1352        cache.set(0, 4);
1353        cache.set(1, 6);
1354        let mut virt: Virtualized<i32> =
1355            Virtualized::new(100).with_item_height(ItemHeight::Variable(cache));
1356        virt.push(1);
1357        virt.push(2);
1358        virt.push(3);
1359
1360        let range = virt.visible_range(10);
1361        assert_eq!(range, 0..2);
1362    }
1363
1364    #[test]
1365    fn test_visible_range_variable_height_default_for_unmeasured() {
1366        let cache = HeightCache::new(2, 16);
1367        let mut virt: Virtualized<i32> =
1368            Virtualized::new(10).with_item_height(ItemHeight::Variable(cache));
1369        for i in 0..3 {
1370            virt.push(i);
1371        }
1372
1373        // Default height = 2, viewport 5 fits 2 items (2 + 2) but not the third.
1374        let range = virt.visible_range(5);
1375        assert_eq!(range, 0..2);
1376    }
1377
1378    #[test]
1379    fn test_render_range_with_overscan() {
1380        let mut virt: Virtualized<i32> =
1381            Virtualized::new(100).with_fixed_height(1).with_overscan(2);
1382        for i in 0..50 {
1383            virt.push(i);
1384        }
1385        virt.scroll(10);
1386        let range = virt.render_range(10);
1387        // Visible: 10..20, Overscan: 2
1388        // Render: 8..22
1389        assert_eq!(range, 8..22);
1390    }
1391
1392    #[test]
1393    fn test_scroll_bounds() {
1394        let mut virt: Virtualized<i32> = Virtualized::new(100);
1395        for i in 0..10 {
1396            virt.push(i);
1397        }
1398
1399        // Can't scroll negative
1400        virt.scroll(-100);
1401        assert_eq!(virt.scroll_offset(), 0);
1402
1403        // Can't scroll past end
1404        virt.scroll(100);
1405        assert_eq!(virt.scroll_offset(), 9);
1406    }
1407
1408    #[test]
1409    fn test_scroll_to() {
1410        let mut virt: Virtualized<i32> = Virtualized::new(100);
1411        for i in 0..20 {
1412            virt.push(i);
1413        }
1414
1415        virt.scroll_to(15);
1416        assert_eq!(virt.scroll_offset(), 15);
1417
1418        // Clamps to max
1419        virt.scroll_to(100);
1420        assert_eq!(virt.scroll_offset(), 19);
1421    }
1422
1423    #[test]
1424    fn test_follow_mode() {
1425        let mut virt: Virtualized<i32> = Virtualized::new(100).with_follow(true);
1426        virt.set_visible_count(5);
1427
1428        for i in 0..10 {
1429            virt.push(i);
1430        }
1431
1432        // Should be at bottom
1433        assert!(virt.is_at_bottom());
1434
1435        // Manual scroll disables follow
1436        virt.scroll(-5);
1437        assert!(!virt.follow_mode());
1438    }
1439
1440    #[test]
1441    fn test_scroll_to_start_and_end() {
1442        let mut virt: Virtualized<i32> = Virtualized::new(100);
1443        virt.set_visible_count(5);
1444        for i in 0..20 {
1445            virt.push(i);
1446        }
1447
1448        // scroll_to_start goes to top and disables follow
1449        virt.scroll_to(10);
1450        virt.set_follow(true);
1451        virt.scroll_to_start();
1452        assert_eq!(virt.scroll_offset(), 0);
1453        assert!(!virt.follow_mode());
1454
1455        // scroll_to_end goes to bottom and enables follow
1456        virt.scroll_to_end();
1457        assert!(virt.is_at_bottom());
1458        assert!(virt.follow_mode());
1459    }
1460
1461    #[test]
1462    fn test_virtualized_page_navigation() {
1463        let mut virt: Virtualized<i32> = Virtualized::new(100);
1464        virt.set_visible_count(5);
1465        for i in 0..30 {
1466            virt.push(i);
1467        }
1468
1469        virt.scroll_to(15);
1470        virt.page_up();
1471        assert_eq!(virt.scroll_offset(), 10);
1472
1473        virt.page_down();
1474        assert_eq!(virt.scroll_offset(), 15);
1475
1476        // Page up at start clamps to 0
1477        virt.scroll_to(2);
1478        virt.page_up();
1479        assert_eq!(virt.scroll_offset(), 0);
1480    }
1481
1482    #[test]
1483    fn test_height_cache() {
1484        let mut cache = HeightCache::new(1, 100);
1485
1486        // Default value
1487        assert_eq!(cache.get(0), 1);
1488        assert_eq!(cache.get(50), 1);
1489
1490        // Set value
1491        cache.set(5, 3);
1492        assert_eq!(cache.get(5), 3);
1493
1494        // Other indices still default
1495        assert_eq!(cache.get(4), 1);
1496        assert_eq!(cache.get(6), 1);
1497    }
1498
1499    #[test]
1500    fn test_height_cache_large_index_window() {
1501        let mut cache = HeightCache::new(1, 8);
1502        cache.set(10_000, 4);
1503        assert_eq!(cache.get(10_000), 4);
1504        assert_eq!(cache.get(0), 1);
1505        assert!(cache.cache.len() <= cache.capacity);
1506    }
1507
1508    #[test]
1509    fn test_clear() {
1510        let mut virt: Virtualized<i32> = Virtualized::new(100);
1511        for i in 0..10 {
1512            virt.push(i);
1513        }
1514        virt.scroll(5);
1515
1516        virt.clear();
1517        assert_eq!(virt.len(), 0);
1518        assert_eq!(virt.scroll_offset(), 0);
1519    }
1520
1521    #[test]
1522    fn test_get_item() {
1523        let mut virt: Virtualized<String> = Virtualized::new(100);
1524        virt.push("hello".to_string());
1525        virt.push("world".to_string());
1526
1527        assert_eq!(virt.get(0), Some(&"hello".to_string()));
1528        assert_eq!(virt.get(1), Some(&"world".to_string()));
1529        assert_eq!(virt.get(2), None);
1530    }
1531
1532    #[test]
1533    fn test_external_storage_len() {
1534        let mut virt: Virtualized<i32> = Virtualized::external(1000, 100);
1535        assert_eq!(virt.len(), 1000);
1536
1537        virt.set_external_len(2000);
1538        assert_eq!(virt.len(), 2000);
1539    }
1540
1541    #[test]
1542    fn test_momentum_scrolling() {
1543        let mut virt: Virtualized<i32> = Virtualized::new(100);
1544        for i in 0..50 {
1545            virt.push(i);
1546        }
1547
1548        virt.fling(10.0);
1549
1550        // Simulate tick
1551        virt.tick(Duration::from_millis(100));
1552
1553        // Should have scrolled
1554        assert!(virt.scroll_offset() > 0);
1555    }
1556
1557    // ========================================================================
1558    // VirtualizedListState tests
1559    // ========================================================================
1560
1561    #[test]
1562    fn test_virtualized_list_state_new() {
1563        let state = VirtualizedListState::new();
1564        assert_eq!(state.selected, None);
1565        assert_eq!(state.scroll_offset(), 0);
1566        assert_eq!(state.visible_count(), 0);
1567    }
1568
1569    #[test]
1570    fn test_virtualized_list_state_select_next() {
1571        let mut state = VirtualizedListState::new();
1572
1573        state.select_next(10);
1574        assert_eq!(state.selected, Some(0));
1575
1576        state.select_next(10);
1577        assert_eq!(state.selected, Some(1));
1578
1579        // At last item, stays there
1580        state.selected = Some(9);
1581        state.select_next(10);
1582        assert_eq!(state.selected, Some(9));
1583    }
1584
1585    #[test]
1586    fn test_virtualized_list_state_select_previous() {
1587        let mut state = VirtualizedListState::new();
1588        state.selected = Some(5);
1589
1590        state.select_previous(10);
1591        assert_eq!(state.selected, Some(4));
1592
1593        state.selected = Some(0);
1594        state.select_previous(10);
1595        assert_eq!(state.selected, Some(0));
1596    }
1597
1598    #[test]
1599    fn test_virtualized_list_state_scroll() {
1600        let mut state = VirtualizedListState::new();
1601
1602        state.scroll(5, 20);
1603        assert_eq!(state.scroll_offset(), 5);
1604
1605        state.scroll(-3, 20);
1606        assert_eq!(state.scroll_offset(), 2);
1607
1608        // Can't scroll negative
1609        state.scroll(-100, 20);
1610        assert_eq!(state.scroll_offset(), 0);
1611
1612        // Can't scroll past end
1613        state.scroll(100, 20);
1614        assert_eq!(state.scroll_offset(), 19);
1615    }
1616
1617    #[test]
1618    fn test_virtualized_list_state_follow_mode() {
1619        let mut state = VirtualizedListState::new().with_follow(true);
1620        assert!(state.follow_mode());
1621
1622        // Manual scroll disables follow
1623        state.scroll(5, 20);
1624        assert!(!state.follow_mode());
1625    }
1626
1627    #[test]
1628    fn test_render_item_string() {
1629        // Verify String implements RenderItem
1630        let s = String::from("hello");
1631        assert_eq!(s.height(), 1);
1632    }
1633
1634    #[test]
1635    fn test_page_up_down() {
1636        let mut virt: Virtualized<i32> = Virtualized::new(100);
1637        for i in 0..50 {
1638            virt.push(i);
1639        }
1640        virt.set_visible_count(10);
1641
1642        // Start at top
1643        assert_eq!(virt.scroll_offset(), 0);
1644
1645        // Page down
1646        virt.page_down();
1647        assert_eq!(virt.scroll_offset(), 10);
1648
1649        // Page down again
1650        virt.page_down();
1651        assert_eq!(virt.scroll_offset(), 20);
1652
1653        // Page up
1654        virt.page_up();
1655        assert_eq!(virt.scroll_offset(), 10);
1656
1657        // Page up again
1658        virt.page_up();
1659        assert_eq!(virt.scroll_offset(), 0);
1660
1661        // Page up at top stays at 0
1662        virt.page_up();
1663        assert_eq!(virt.scroll_offset(), 0);
1664    }
1665
1666    // ========================================================================
1667    // Performance invariant tests (bd-uo6v)
1668    // ========================================================================
1669
1670    #[test]
1671    fn test_render_scales_with_visible_not_total() {
1672        use ftui_render::grapheme_pool::GraphemePool;
1673        use std::time::Instant;
1674
1675        // Setup: VirtualizedList with 1K items
1676        let small_items: Vec<String> = (0..1_000).map(|i| format!("Line {}", i)).collect();
1677        let small_list = VirtualizedList::new(&small_items);
1678        let mut small_state = VirtualizedListState::new();
1679
1680        let area = Rect::new(0, 0, 80, 24);
1681        let mut pool = GraphemePool::new();
1682        let mut frame = Frame::new(80, 24, &mut pool);
1683
1684        // Warm up
1685        small_list.render(area, &mut frame, &mut small_state);
1686
1687        let start = Instant::now();
1688        for _ in 0..100 {
1689            frame.buffer.clear();
1690            small_list.render(area, &mut frame, &mut small_state);
1691        }
1692        let small_time = start.elapsed();
1693
1694        // Setup: VirtualizedList with 100K items
1695        let large_items: Vec<String> = (0..100_000).map(|i| format!("Line {}", i)).collect();
1696        let large_list = VirtualizedList::new(&large_items);
1697        let mut large_state = VirtualizedListState::new();
1698
1699        // Warm up
1700        large_list.render(area, &mut frame, &mut large_state);
1701
1702        let start = Instant::now();
1703        for _ in 0..100 {
1704            frame.buffer.clear();
1705            large_list.render(area, &mut frame, &mut large_state);
1706        }
1707        let large_time = start.elapsed();
1708
1709        // 100K should be within 3x of 1K (both render ~24 items)
1710        assert!(
1711            large_time < small_time * 3,
1712            "Render does not scale O(visible): 1K={:?}, 100K={:?}",
1713            small_time,
1714            large_time
1715        );
1716    }
1717
1718    #[test]
1719    fn test_scroll_is_constant_time() {
1720        use std::time::Instant;
1721
1722        let mut small: Virtualized<i32> = Virtualized::new(1_000);
1723        for i in 0..1_000 {
1724            small.push(i);
1725        }
1726        small.set_visible_count(24);
1727
1728        let mut large: Virtualized<i32> = Virtualized::new(100_000);
1729        for i in 0..100_000 {
1730            large.push(i);
1731        }
1732        large.set_visible_count(24);
1733
1734        let iterations = 10_000;
1735
1736        let start = Instant::now();
1737        for _ in 0..iterations {
1738            small.scroll(1);
1739            small.scroll(-1);
1740        }
1741        let small_time = start.elapsed();
1742
1743        let start = Instant::now();
1744        for _ in 0..iterations {
1745            large.scroll(1);
1746            large.scroll(-1);
1747        }
1748        let large_time = start.elapsed();
1749
1750        // Should be within 3x (both are O(1) operations)
1751        assert!(
1752            large_time < small_time * 3,
1753            "Scroll is not O(1): 1K={:?}, 100K={:?}",
1754            small_time,
1755            large_time
1756        );
1757    }
1758
1759    #[test]
1760    fn render_partially_offscreen_top_skips_item() {
1761        use ftui_render::grapheme_pool::GraphemePool;
1762
1763        // Items with height 2, each rendering its index as a character
1764        struct IndexedItem(usize);
1765        impl RenderItem for IndexedItem {
1766            fn render(&self, area: Rect, frame: &mut Frame, _selected: bool) {
1767                let ch = char::from_digit(self.0 as u32, 10).unwrap();
1768                for y in area.y..area.bottom() {
1769                    frame.buffer.set(area.x, y, Cell::from_char(ch));
1770                }
1771            }
1772            fn height(&self) -> u16 {
1773                2
1774            }
1775        }
1776
1777        // Need 4+ items so scroll_offset=1 is valid:
1778        // items_per_viewport = 5/2 = 2, max_offset = 4-2 = 2
1779        let items = vec![
1780            IndexedItem(0),
1781            IndexedItem(1),
1782            IndexedItem(2),
1783            IndexedItem(3),
1784        ];
1785        let list = VirtualizedList::new(&items).fixed_height(2);
1786
1787        // Scroll so item 1 is at top, item 0 is in overscan (above viewport)
1788        let mut state = VirtualizedListState::new().with_overscan(1);
1789        state.scroll_offset = 1; // Item 1 is top visible. Item 0 is in overscan.
1790
1791        let mut pool = GraphemePool::new();
1792        let mut frame = Frame::new(10, 5, &mut pool);
1793
1794        // Render at y=0 (terminal top edge)
1795        list.render(Rect::new(0, 0, 10, 5), &mut frame, &mut state);
1796
1797        // With scroll_offset=1 and overscan=1:
1798        // - render_start = 1 - 1 = 0 (include item 0 in overscan)
1799        // - Item 0 would render at y_offset = (0-1)*2 = -2
1800        // - area.y + y_offset = 0 + (-2) = -2 < 0, so item 0 must be SKIPPED
1801        // - Item 1 renders at y_offset = (1-1)*2 = 0
1802        //
1803        // Row 0 should be '1' (from Item 1), NOT '0' (from Item 0 ghosting)
1804        let cell = frame.buffer.get(0, 0).unwrap();
1805        assert_eq!(cell.content.as_char(), Some('1'));
1806    }
1807
1808    #[test]
1809    fn render_bottom_boundary_clips_partial_item() {
1810        use ftui_render::grapheme_pool::GraphemePool;
1811
1812        struct IndexedItem(u16);
1813        impl RenderItem for IndexedItem {
1814            fn render(&self, area: Rect, frame: &mut Frame, _selected: bool) {
1815                let ch = char::from_digit(self.0 as u32, 10).unwrap();
1816                for y in area.y..area.bottom() {
1817                    frame.buffer.set(area.x, y, Cell::from_char(ch));
1818                }
1819            }
1820            fn height(&self) -> u16 {
1821                2
1822            }
1823        }
1824
1825        let items = vec![IndexedItem(0), IndexedItem(1), IndexedItem(2)];
1826        let list = VirtualizedList::new(&items)
1827            .fixed_height(2)
1828            .show_scrollbar(false);
1829        let mut state = VirtualizedListState::new();
1830
1831        let mut pool = GraphemePool::new();
1832        let mut frame = Frame::new(4, 4, &mut pool);
1833
1834        // Viewport height 3 means the second item is only partially visible.
1835        list.render(Rect::new(0, 0, 4, 3), &mut frame, &mut state);
1836
1837        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('0'));
1838        assert_eq!(frame.buffer.get(0, 1).unwrap().content.as_char(), Some('0'));
1839        assert_eq!(frame.buffer.get(0, 2).unwrap().content.as_char(), Some('1'));
1840        // Row outside the viewport should remain empty.
1841        assert_eq!(frame.buffer.get(0, 3).unwrap().content.as_char(), None);
1842    }
1843
1844    #[test]
1845    fn render_after_fling_advances_visible_rows() {
1846        use ftui_render::grapheme_pool::GraphemePool;
1847
1848        struct IndexedItem(u16);
1849        impl RenderItem for IndexedItem {
1850            fn render(&self, area: Rect, frame: &mut Frame, _selected: bool) {
1851                let ch = char::from_digit(self.0 as u32, 10).unwrap();
1852                for y in area.y..area.bottom() {
1853                    frame.buffer.set(area.x, y, Cell::from_char(ch));
1854                }
1855            }
1856        }
1857
1858        let items: Vec<IndexedItem> = (0..10).map(IndexedItem).collect();
1859        let list = VirtualizedList::new(&items)
1860            .fixed_height(1)
1861            .show_scrollbar(false);
1862        let mut state = VirtualizedListState::new();
1863
1864        let mut pool = GraphemePool::new();
1865        let mut frame = Frame::new(4, 3, &mut pool);
1866        let area = Rect::new(0, 0, 4, 3);
1867
1868        // Initial render establishes visible_count and baseline top row.
1869        list.render(area, &mut frame, &mut state);
1870        assert_eq!(state.scroll_offset(), 0);
1871        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('0'));
1872
1873        // Momentum scroll: 40.0 * 0.1s = 4 rows.
1874        state.fling(40.0);
1875        state.tick(Duration::from_millis(100), items.len());
1876        assert_eq!(state.scroll_offset(), 4);
1877
1878        frame.buffer.clear();
1879        list.render(area, &mut frame, &mut state);
1880        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('4'));
1881    }
1882
1883    #[test]
1884    fn test_memory_bounded_by_ring_capacity() {
1885        use crate::log_ring::LogRing;
1886
1887        let mut ring: LogRing<String> = LogRing::new(1_000);
1888
1889        // Add 100K items
1890        for i in 0..100_000 {
1891            ring.push(format!("Line {}", i));
1892        }
1893
1894        // Only 1K in memory
1895        assert_eq!(ring.len(), 1_000);
1896        assert_eq!(ring.total_count(), 100_000);
1897        assert_eq!(ring.first_index(), 99_000);
1898
1899        // Can still access recent items
1900        assert!(ring.get(99_999).is_some());
1901        assert!(ring.get(99_000).is_some());
1902        // Old items evicted
1903        assert!(ring.get(0).is_none());
1904        assert!(ring.get(98_999).is_none());
1905    }
1906
1907    #[test]
1908    fn test_visible_range_constant_regardless_of_total() {
1909        let mut small: Virtualized<i32> = Virtualized::new(100);
1910        for i in 0..100 {
1911            small.push(i);
1912        }
1913        let small_range = small.visible_range(24);
1914
1915        let mut large: Virtualized<i32> = Virtualized::new(100_000);
1916        for i in 0..100_000 {
1917            large.push(i);
1918        }
1919        let large_range = large.visible_range(24);
1920
1921        // Both should return exactly 24 visible items
1922        assert_eq!(small_range.end - small_range.start, 24);
1923        assert_eq!(large_range.end - large_range.start, 24);
1924    }
1925
1926    #[test]
1927    fn test_virtualized_list_state_page_up_down() {
1928        let mut state = VirtualizedListState::new();
1929        state.visible_count = 10;
1930
1931        // Page down
1932        state.page_down(50);
1933        assert_eq!(state.scroll_offset(), 10);
1934
1935        // Page down again
1936        state.page_down(50);
1937        assert_eq!(state.scroll_offset(), 20);
1938
1939        // Page up
1940        state.page_up(50);
1941        assert_eq!(state.scroll_offset(), 10);
1942
1943        // Page up again
1944        state.page_up(50);
1945        assert_eq!(state.scroll_offset(), 0);
1946    }
1947
1948    // ========================================================================
1949    // VariableHeightsFenwick tests (bd-2zbk.7)
1950    // ========================================================================
1951
1952    #[test]
1953    fn test_variable_heights_fenwick_new() {
1954        let tracker = VariableHeightsFenwick::new(2, 10);
1955        assert_eq!(tracker.len(), 10);
1956        assert!(!tracker.is_empty());
1957        assert_eq!(tracker.default_height(), 2);
1958    }
1959
1960    #[test]
1961    fn test_variable_heights_fenwick_empty() {
1962        let tracker = VariableHeightsFenwick::new(1, 0);
1963        assert!(tracker.is_empty());
1964        assert_eq!(tracker.total_height(), 0);
1965    }
1966
1967    #[test]
1968    fn test_variable_heights_fenwick_from_heights() {
1969        let heights = vec![3, 2, 5, 1, 4];
1970        let tracker = VariableHeightsFenwick::from_heights(&heights, 1);
1971
1972        assert_eq!(tracker.len(), 5);
1973        assert_eq!(tracker.get(0), 3);
1974        assert_eq!(tracker.get(1), 2);
1975        assert_eq!(tracker.get(2), 5);
1976        assert_eq!(tracker.get(3), 1);
1977        assert_eq!(tracker.get(4), 4);
1978        assert_eq!(tracker.total_height(), 15);
1979    }
1980
1981    #[test]
1982    fn test_variable_heights_fenwick_offset_of_item() {
1983        // Heights: [3, 2, 5, 1, 4] -> offsets: [0, 3, 5, 10, 11]
1984        let heights = vec![3, 2, 5, 1, 4];
1985        let tracker = VariableHeightsFenwick::from_heights(&heights, 1);
1986
1987        assert_eq!(tracker.offset_of_item(0), 0);
1988        assert_eq!(tracker.offset_of_item(1), 3);
1989        assert_eq!(tracker.offset_of_item(2), 5);
1990        assert_eq!(tracker.offset_of_item(3), 10);
1991        assert_eq!(tracker.offset_of_item(4), 11);
1992        assert_eq!(tracker.offset_of_item(5), 15); // beyond end
1993    }
1994
1995    #[test]
1996    fn test_variable_heights_fenwick_find_item_at_offset() {
1997        // Heights: [3, 2, 5, 1, 4] -> cumulative: [3, 5, 10, 11, 15]
1998        let heights = vec![3, 2, 5, 1, 4];
1999        let tracker = VariableHeightsFenwick::from_heights(&heights, 1);
2000
2001        // Offset 0 should be item 0
2002        assert_eq!(tracker.find_item_at_offset(0), 0);
2003        // Offset 1 should be item 0 (within first item)
2004        assert_eq!(tracker.find_item_at_offset(1), 0);
2005        // Offset 3 should be item 1 (starts at offset 3)
2006        assert_eq!(tracker.find_item_at_offset(3), 1);
2007        // Offset 5 should be item 2
2008        assert_eq!(tracker.find_item_at_offset(5), 2);
2009        // Offset 10 should be item 3
2010        assert_eq!(tracker.find_item_at_offset(10), 3);
2011        // Offset 11 should be item 4
2012        assert_eq!(tracker.find_item_at_offset(11), 4);
2013        // Offset 15 should be end (beyond all items)
2014        assert_eq!(tracker.find_item_at_offset(15), 5);
2015    }
2016
2017    #[test]
2018    fn test_variable_heights_fenwick_visible_count() {
2019        // Heights: [3, 2, 5, 1, 4]
2020        let heights = vec![3, 2, 5, 1, 4];
2021        let tracker = VariableHeightsFenwick::from_heights(&heights, 1);
2022
2023        // Viewport 5: items 0 (h=3) + 1 (h=2) = 5 exactly
2024        assert_eq!(tracker.visible_count(0, 5), 2);
2025
2026        // Viewport 4: item 0 (h=3) fits, item 1 (h=2) doesn't fit fully
2027        assert_eq!(tracker.visible_count(0, 4), 1);
2028
2029        // Viewport 10: items 0+1+2 = 10 exactly
2030        assert_eq!(tracker.visible_count(0, 10), 3);
2031
2032        // From item 2, viewport 6: item 2 (h=5) + item 3 (h=1) = 6
2033        assert_eq!(tracker.visible_count(2, 6), 2);
2034    }
2035
2036    #[test]
2037    fn test_variable_heights_fenwick_set() {
2038        let mut tracker = VariableHeightsFenwick::new(1, 5);
2039
2040        // All items should start with default height
2041        assert_eq!(tracker.get(0), 1);
2042        assert_eq!(tracker.total_height(), 5);
2043
2044        // Set item 2 to height 10
2045        tracker.set(2, 10);
2046        assert_eq!(tracker.get(2), 10);
2047        assert_eq!(tracker.total_height(), 14); // 1+1+10+1+1
2048    }
2049
2050    #[test]
2051    fn test_variable_heights_fenwick_resize() {
2052        let mut tracker = VariableHeightsFenwick::new(2, 3);
2053        assert_eq!(tracker.len(), 3);
2054        assert_eq!(tracker.total_height(), 6);
2055
2056        // Grow
2057        tracker.resize(5);
2058        assert_eq!(tracker.len(), 5);
2059        assert_eq!(tracker.total_height(), 10);
2060        assert_eq!(tracker.get(4), 2);
2061
2062        // Shrink
2063        tracker.resize(2);
2064        assert_eq!(tracker.len(), 2);
2065        assert_eq!(tracker.total_height(), 4);
2066    }
2067
2068    #[test]
2069    fn test_virtualized_with_variable_heights_fenwick() {
2070        let mut virt: Virtualized<i32> = Virtualized::new(100).with_variable_heights_fenwick(2, 10);
2071
2072        for i in 0..10 {
2073            virt.push(i);
2074        }
2075
2076        // All items height 2, viewport 6 -> 3 items visible
2077        let range = virt.visible_range(6);
2078        assert_eq!(range.end - range.start, 3);
2079    }
2080
2081    #[test]
2082    fn test_variable_heights_fenwick_performance() {
2083        use std::time::Instant;
2084
2085        // Create large tracker
2086        let n = 100_000;
2087        let heights: Vec<u16> = (0..n).map(|i| (i % 10 + 1) as u16).collect();
2088        let tracker = VariableHeightsFenwick::from_heights(&heights, 1);
2089
2090        // Warm up
2091        let _ = tracker.find_item_at_offset(500_000);
2092        let _ = tracker.offset_of_item(50_000);
2093
2094        // Benchmark find_item_at_offset (O(log n))
2095        let start = Instant::now();
2096        let mut _sink = 0usize;
2097        for i in 0..10_000 {
2098            _sink = _sink.wrapping_add(tracker.find_item_at_offset((i * 50) as u32));
2099        }
2100        let find_time = start.elapsed();
2101
2102        // Benchmark offset_of_item (O(log n))
2103        let start = Instant::now();
2104        let mut _sink2 = 0u32;
2105        for i in 0..10_000 {
2106            _sink2 = _sink2.wrapping_add(tracker.offset_of_item((i * 10) % n));
2107        }
2108        let offset_time = start.elapsed();
2109
2110        eprintln!("=== VariableHeightsFenwick Performance (n={n}) ===");
2111        eprintln!("10k find_item_at_offset: {:?}", find_time);
2112        eprintln!("10k offset_of_item:      {:?}", offset_time);
2113
2114        // Both should be under 50ms for 10k operations
2115        assert!(
2116            find_time < std::time::Duration::from_millis(50),
2117            "find_item_at_offset too slow: {:?}",
2118            find_time
2119        );
2120        assert!(
2121            offset_time < std::time::Duration::from_millis(50),
2122            "offset_of_item too slow: {:?}",
2123            offset_time
2124        );
2125    }
2126
2127    #[test]
2128    fn test_variable_heights_fenwick_scales_logarithmically() {
2129        use std::time::Instant;
2130
2131        // Small dataset
2132        let small_n = 1_000;
2133        let small_heights: Vec<u16> = (0..small_n).map(|i| (i % 5 + 1) as u16).collect();
2134        let small_tracker = VariableHeightsFenwick::from_heights(&small_heights, 1);
2135
2136        // Large dataset
2137        let large_n = 100_000;
2138        let large_heights: Vec<u16> = (0..large_n).map(|i| (i % 5 + 1) as u16).collect();
2139        let large_tracker = VariableHeightsFenwick::from_heights(&large_heights, 1);
2140
2141        let iterations = 5_000;
2142
2143        // Time small
2144        let start = Instant::now();
2145        for i in 0..iterations {
2146            let _ = small_tracker.find_item_at_offset((i * 2) as u32);
2147        }
2148        let small_time = start.elapsed();
2149
2150        // Time large
2151        let start = Instant::now();
2152        for i in 0..iterations {
2153            let _ = large_tracker.find_item_at_offset((i * 200) as u32);
2154        }
2155        let large_time = start.elapsed();
2156
2157        // Large should be within 5x of small (O(log n) vs O(n) would be 100x)
2158        assert!(
2159            large_time < small_time * 5,
2160            "Not O(log n): small={:?}, large={:?}",
2161            small_time,
2162            large_time
2163        );
2164    }
2165}