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
38use crate::scrollbar::{Scrollbar, ScrollbarOrientation, ScrollbarState};
39use crate::{StatefulWidget, set_style_area};
40use ftui_core::geometry::Rect;
41use ftui_render::cell::Cell;
42use ftui_render::frame::Frame;
43use ftui_style::Style;
44
45/// A virtualized content container that tracks scroll state and computes visible ranges.
46///
47/// # Design Rationale
48/// - Generic over item type for flexibility
49/// - Supports both owned storage and external data sources
50/// - Computes visible ranges for O(visible) rendering
51/// - Optional overscan for smooth scrolling
52/// - Momentum scrolling support
53#[derive(Debug, Clone)]
54pub struct Virtualized<T> {
55    /// The stored items (or external storage reference).
56    storage: VirtualizedStorage<T>,
57    /// Current scroll offset (in items).
58    scroll_offset: usize,
59    /// Number of visible items (cached from last render).
60    visible_count: StdCell<usize>,
61    /// Overscan: extra items rendered above/below visible.
62    overscan: usize,
63    /// Height calculation strategy.
64    item_height: ItemHeight,
65    /// Whether to auto-scroll on new items.
66    follow_mode: bool,
67    /// Scroll velocity for momentum scrolling.
68    scroll_velocity: f32,
69}
70
71/// Storage strategy for virtualized items.
72#[derive(Debug, Clone)]
73pub enum VirtualizedStorage<T> {
74    /// Owned vector of items.
75    Owned(VecDeque<T>),
76    /// External storage with known length.
77    /// Note: External fetch is handled at the widget level.
78    External {
79        /// Total number of items available.
80        len: usize,
81        /// Maximum items to keep in local cache.
82        cache_capacity: usize,
83    },
84}
85
86/// Height calculation strategy for items.
87#[derive(Debug, Clone)]
88pub enum ItemHeight {
89    /// All items have fixed height.
90    Fixed(u16),
91    /// Items have variable height, cached lazily (linear scan).
92    Variable(HeightCache),
93    /// Items have variable height with O(log n) scroll-to-index via Fenwick tree.
94    VariableFenwick(VariableHeightsFenwick),
95}
96
97/// LRU cache for measured item heights.
98#[derive(Debug, Clone)]
99pub struct HeightCache {
100    /// Height measurements indexed by (item index - base_offset).
101    cache: Vec<Option<u16>>,
102    /// Offset of the first entry in the cache (cache[0] corresponds to this item index).
103    base_offset: usize,
104    /// Default height for unmeasured items.
105    default_height: u16,
106    /// Maximum entries to cache (for memory bounds).
107    capacity: usize,
108}
109
110impl<T> Virtualized<T> {
111    /// Create a new virtualized container with owned storage.
112    ///
113    /// # Arguments
114    /// * `capacity` - Maximum items to retain in memory.
115    #[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    /// Create with external storage reference.
129    #[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    /// Set item height strategy.
146    #[must_use]
147    pub fn with_item_height(mut self, height: ItemHeight) -> Self {
148        self.item_height = height;
149        self
150    }
151
152    /// Set fixed item height.
153    #[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    /// Set variable heights with O(log n) Fenwick tree tracking.
160    ///
161    /// This is more efficient than `Variable(HeightCache)` for large lists
162    /// as scroll-to-index mapping is O(log n) instead of O(visible).
163    #[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    /// Set overscan amount.
171    #[must_use]
172    pub fn with_overscan(mut self, overscan: usize) -> Self {
173        self.overscan = overscan;
174        self
175    }
176
177    /// Enable follow mode.
178    #[must_use]
179    pub fn with_follow(mut self, follow: bool) -> Self {
180        self.follow_mode = follow;
181        self
182    }
183
184    /// Get total number of items.
185    #[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    /// Check if empty.
194    #[must_use]
195    pub fn is_empty(&self) -> bool {
196        self.len() == 0
197    }
198
199    /// Get current scroll offset.
200    #[must_use]
201    pub fn scroll_offset(&self) -> usize {
202        self.scroll_offset
203    }
204
205    /// Get current visible count (from last render).
206    #[must_use]
207    pub fn visible_count(&self) -> usize {
208        self.visible_count.get()
209    }
210
211    /// Check if follow mode is enabled.
212    #[must_use]
213    pub fn follow_mode(&self) -> bool {
214        self.follow_mode
215    }
216
217    /// Calculate visible range for given viewport height.
218    #[must_use]
219    pub fn visible_range(&self, viewport_height: u16) -> Range<usize> {
220        if self.is_empty() || viewport_height == 0 {
221            self.visible_count.set(0);
222            return 0..0;
223        }
224
225        let items_visible = match &self.item_height {
226            ItemHeight::Fixed(h) if *h > 0 => (viewport_height / h) as usize,
227            ItemHeight::Fixed(_) => viewport_height as usize,
228            ItemHeight::Variable(cache) => {
229                // Sum heights until the next item would exceed viewport (O(visible))
230                let mut count = 0;
231                let mut total_height = 0u16;
232                let start = self.scroll_offset;
233                while start + count < self.len() {
234                    let next = cache.get(start + count);
235                    let proposed = total_height.saturating_add(next);
236                    if proposed > viewport_height {
237                        break;
238                    }
239                    total_height = proposed;
240                    count += 1;
241                }
242                count
243            }
244            ItemHeight::VariableFenwick(tracker) => {
245                // O(log n) using Fenwick tree
246                tracker.visible_count(self.scroll_offset, viewport_height)
247            }
248        };
249
250        let start = self.scroll_offset;
251        let end = (start + items_visible).min(self.len());
252        self.visible_count.set(items_visible);
253        start..end
254    }
255
256    /// Get render range with overscan for smooth scrolling.
257    #[must_use]
258    pub fn render_range(&self, viewport_height: u16) -> Range<usize> {
259        let visible = self.visible_range(viewport_height);
260        let start = visible.start.saturating_sub(self.overscan);
261        let end = visible.end.saturating_add(self.overscan).min(self.len());
262        start..end
263    }
264
265    /// Scroll by delta (positive = down/forward).
266    pub fn scroll(&mut self, delta: i32) {
267        if self.is_empty() {
268            return;
269        }
270        let visible_count = self.visible_count.get();
271        let max_offset = if visible_count > 0 {
272            self.len().saturating_sub(visible_count)
273        } else {
274            self.len().saturating_sub(1)
275        };
276        let new_offset = (self.scroll_offset as i64 + delta as i64)
277            .max(0)
278            .min(max_offset as i64);
279        self.scroll_offset = new_offset as usize;
280
281        // Disable follow mode on manual scroll
282        if delta != 0 {
283            self.follow_mode = false;
284        }
285    }
286
287    /// Scroll to specific item index.
288    pub fn scroll_to(&mut self, idx: usize) {
289        self.scroll_offset = idx.min(self.len().saturating_sub(1));
290        self.follow_mode = false;
291    }
292
293    /// Scroll to bottom.
294    pub fn scroll_to_bottom(&mut self) {
295        let visible_count = self.visible_count.get();
296        if self.len() > visible_count && visible_count > 0 {
297            self.scroll_offset = self.len().saturating_sub(visible_count);
298        } else {
299            self.scroll_offset = 0;
300        }
301    }
302
303    /// Scroll to top.
304    pub fn scroll_to_top(&mut self) {
305        self.scroll_offset = 0;
306        self.follow_mode = false;
307    }
308
309    /// Alias for scroll_to_top (Home key).
310    pub fn scroll_to_start(&mut self) {
311        self.scroll_to_top();
312    }
313
314    /// Scroll to bottom and enable follow mode (End key).
315    pub fn scroll_to_end(&mut self) {
316        self.scroll_to_bottom();
317        self.follow_mode = true;
318    }
319
320    /// Page up (scroll by visible count).
321    pub fn page_up(&mut self) {
322        let visible_count = self.visible_count.get();
323        if visible_count > 0 {
324            let delta = i32::try_from(visible_count).unwrap_or(i32::MAX);
325            self.scroll(-delta);
326        }
327    }
328
329    /// Page down (scroll by visible count).
330    pub fn page_down(&mut self) {
331        let visible_count = self.visible_count.get();
332        if visible_count > 0 {
333            let delta = i32::try_from(visible_count).unwrap_or(i32::MAX);
334            self.scroll(delta);
335        }
336    }
337
338    /// Set follow mode.
339    pub fn set_follow(&mut self, follow: bool) {
340        self.follow_mode = follow;
341        if follow {
342            self.scroll_to_bottom();
343        }
344    }
345
346    /// Check if scrolled to bottom.
347    #[must_use]
348    pub fn is_at_bottom(&self) -> bool {
349        let visible_count = self.visible_count.get();
350        if self.len() <= visible_count {
351            true
352        } else {
353            self.scroll_offset >= self.len().saturating_sub(visible_count)
354        }
355    }
356
357    /// Start momentum scroll.
358    pub fn fling(&mut self, velocity: f32) {
359        self.scroll_velocity = velocity;
360    }
361
362    /// Apply momentum scroll tick.
363    pub fn tick(&mut self, dt: Duration) {
364        if self.scroll_velocity.abs() > 0.1 {
365            let delta = (self.scroll_velocity * dt.as_secs_f32()) as i32;
366            if delta != 0 {
367                self.scroll(delta);
368            }
369            // Apply friction
370            self.scroll_velocity *= 0.95;
371        } else {
372            self.scroll_velocity = 0.0;
373        }
374    }
375
376    /// Update visible count (called during render).
377    pub fn set_visible_count(&mut self, count: usize) {
378        self.visible_count.set(count);
379    }
380}
381
382impl<T> Virtualized<T> {
383    /// Push an item (owned storage only).
384    pub fn push(&mut self, item: T) {
385        if let VirtualizedStorage::Owned(items) = &mut self.storage {
386            items.push_back(item);
387            if self.follow_mode {
388                self.scroll_to_bottom();
389            }
390        }
391    }
392
393    /// Get item by index (owned storage only).
394    #[must_use = "use the returned item (if any)"]
395    pub fn get(&self, idx: usize) -> Option<&T> {
396        if let VirtualizedStorage::Owned(items) = &self.storage {
397            items.get(idx)
398        } else {
399            None
400        }
401    }
402
403    /// Get mutable item by index (owned storage only).
404    #[must_use = "use the returned item (if any)"]
405    pub fn get_mut(&mut self, idx: usize) -> Option<&mut T> {
406        if let VirtualizedStorage::Owned(items) = &mut self.storage {
407            items.get_mut(idx)
408        } else {
409            None
410        }
411    }
412
413    /// Clear all items (owned storage only).
414    pub fn clear(&mut self) {
415        if let VirtualizedStorage::Owned(items) = &mut self.storage {
416            items.clear();
417        }
418        self.scroll_offset = 0;
419    }
420
421    /// Trim items from the front to keep at most `max` items (owned storage only).
422    ///
423    /// Returns the number of items removed.
424    pub fn trim_front(&mut self, max: usize) -> usize {
425        if let VirtualizedStorage::Owned(items) = &mut self.storage
426            && items.len() > max
427        {
428            let to_remove = items.len() - max;
429            items.drain(..to_remove);
430            // Adjust scroll_offset if it was pointing beyond the new start
431            self.scroll_offset = self.scroll_offset.saturating_sub(to_remove);
432            return to_remove;
433        }
434        0
435    }
436
437    /// Iterate over items (owned storage only).
438    /// Returns empty iterator for external storage.
439    pub fn iter(&self) -> Box<dyn Iterator<Item = &T> + '_> {
440        match &self.storage {
441            VirtualizedStorage::Owned(items) => Box::new(items.iter()),
442            VirtualizedStorage::External { .. } => Box::new(std::iter::empty()),
443        }
444    }
445
446    /// Update external storage length.
447    pub fn set_external_len(&mut self, len: usize) {
448        if let VirtualizedStorage::External { len: l, .. } = &mut self.storage {
449            *l = len;
450            if self.follow_mode {
451                self.scroll_to_bottom();
452            }
453        }
454    }
455}
456
457impl Default for HeightCache {
458    fn default() -> Self {
459        Self::new(1, 1000)
460    }
461}
462
463impl HeightCache {
464    /// Create a new height cache.
465    #[must_use]
466    pub fn new(default_height: u16, capacity: usize) -> Self {
467        Self {
468            cache: Vec::new(),
469            base_offset: 0,
470            default_height,
471            capacity,
472        }
473    }
474
475    /// Get height for item, returning default if not cached.
476    #[must_use]
477    pub fn get(&self, idx: usize) -> u16 {
478        if idx < self.base_offset {
479            return self.default_height;
480        }
481        let local = idx - self.base_offset;
482        self.cache
483            .get(local)
484            .and_then(|h| *h)
485            .unwrap_or(self.default_height)
486    }
487
488    /// Set height for item.
489    pub fn set(&mut self, idx: usize, height: u16) {
490        if self.capacity == 0 {
491            return;
492        }
493        if idx < self.base_offset {
494            // Index has been trimmed away; ignore
495            return;
496        }
497        let mut local = idx - self.base_offset;
498        if local >= self.capacity {
499            // Large index jump: reset window to avoid huge allocations.
500            self.base_offset = idx.saturating_add(1).saturating_sub(self.capacity);
501            self.cache.clear();
502            local = idx - self.base_offset;
503        }
504        if local >= self.cache.len() {
505            self.cache.resize(local + 1, None);
506        }
507        self.cache[local] = Some(height);
508
509        // Trim if over capacity: remove oldest entries and adjust base_offset
510        if self.cache.len() > self.capacity {
511            let to_remove = self.cache.len() - self.capacity;
512            self.cache.drain(0..to_remove);
513            self.base_offset += to_remove;
514        }
515    }
516
517    /// Clear cached heights.
518    pub fn clear(&mut self) {
519        self.cache.clear();
520        self.base_offset = 0;
521    }
522}
523
524// ============================================================================
525// VariableHeightsFenwick - O(log n) scroll-to-index mapping
526// ============================================================================
527
528use crate::fenwick::FenwickTree;
529
530/// Variable height tracker using Fenwick tree for O(log n) prefix sum queries.
531///
532/// This enables efficient scroll offset to item index mapping for virtualized
533/// lists with variable height items.
534///
535/// # Operations
536///
537/// | Operation | Time |
538/// |-----------|------|
539/// | `find_item_at_offset` | O(log n) |
540/// | `offset_of_item` | O(log n) |
541/// | `set_height` | O(log n) |
542/// | `total_height` | O(log n) |
543///
544/// # Invariants
545///
546/// 1. `tree.prefix(i)` == sum of heights [0..=i]
547/// 2. `find_item_at_offset(offset)` returns largest i where prefix(i-1) < offset
548/// 3. Heights are u32 internally (u16 input widened for large lists)
549#[derive(Debug, Clone)]
550pub struct VariableHeightsFenwick {
551    /// Fenwick tree storing item heights.
552    tree: FenwickTree,
553    /// Default height for items not yet measured.
554    default_height: u16,
555    /// Number of items tracked.
556    len: usize,
557}
558
559impl Default for VariableHeightsFenwick {
560    fn default() -> Self {
561        Self::new(1, 0)
562    }
563}
564
565impl VariableHeightsFenwick {
566    /// Create a new height tracker with given default height and initial capacity.
567    #[must_use]
568    pub fn new(default_height: u16, capacity: usize) -> Self {
569        let tree = if capacity > 0 {
570            // Initialize with default heights
571            let heights: Vec<u32> = vec![u32::from(default_height); capacity];
572            FenwickTree::from_values(&heights)
573        } else {
574            FenwickTree::new(0)
575        };
576        Self {
577            tree,
578            default_height,
579            len: capacity,
580        }
581    }
582
583    /// Create from a slice of heights.
584    #[must_use]
585    pub fn from_heights(heights: &[u16], default_height: u16) -> Self {
586        let heights_u32: Vec<u32> = heights.iter().map(|&h| u32::from(h)).collect();
587        Self {
588            tree: FenwickTree::from_values(&heights_u32),
589            default_height,
590            len: heights.len(),
591        }
592    }
593
594    /// Number of items tracked.
595    #[must_use]
596    pub fn len(&self) -> usize {
597        self.len
598    }
599
600    /// Whether tracking is empty.
601    #[must_use]
602    pub fn is_empty(&self) -> bool {
603        self.len == 0
604    }
605
606    /// Get the default height for unmeasured items.
607    #[must_use]
608    pub fn default_height(&self) -> u16 {
609        self.default_height
610    }
611
612    /// Get height of a specific item. O(log n).
613    #[must_use]
614    pub fn get(&self, idx: usize) -> u16 {
615        if idx >= self.len {
616            return self.default_height;
617        }
618        // Fenwick get returns the individual value at idx
619        self.tree.get(idx).min(u32::from(u16::MAX)) as u16
620    }
621
622    /// Set height of a specific item. O(log n).
623    pub fn set(&mut self, idx: usize, height: u16) {
624        if idx >= self.len {
625            // Need to resize
626            self.resize(idx + 1);
627        }
628        self.tree.set(idx, u32::from(height));
629    }
630
631    /// Get the y-offset (in pixels/rows) of an item. O(log n).
632    ///
633    /// Returns the sum of heights of all items before `idx`.
634    #[must_use]
635    pub fn offset_of_item(&self, idx: usize) -> u32 {
636        if idx == 0 || self.len == 0 {
637            return 0;
638        }
639        let clamped = idx.min(self.len);
640        if clamped > 0 {
641            self.tree.prefix(clamped - 1)
642        } else {
643            0
644        }
645    }
646
647    /// Find the item index at a given scroll offset. O(log n).
648    ///
649    /// Returns the index of the item that occupies the given offset.
650    /// If offset is beyond all items, returns `self.len`.
651    ///
652    /// Item i occupies offsets [offset_of_item(i), offset_of_item(i+1)).
653    #[must_use]
654    pub fn find_item_at_offset(&self, offset: u32) -> usize {
655        if self.len == 0 {
656            return 0;
657        }
658        if offset == 0 {
659            return 0;
660        }
661        // find_prefix returns largest i where prefix(i) <= offset
662        // prefix(i) = sum of heights [0..=i] = y-coordinate just past item i
663        // If prefix(i) <= offset, then offset is at or past the end of item i,
664        // so offset is in item i+1.
665        //
666        // We use `offset` directly (not `offset - 1`). When `offset == prefix(i)` exactly,
667        // `find_prefix` returns `i`, and we correctly map that to item `i+1` below.
668        match self.tree.find_prefix(offset) {
669            Some(i) => {
670                // prefix(i) <= offset
671                // Item i spans [prefix(i-1), prefix(i)), so offset >= prefix(i)
672                // means offset is in item i+1 or beyond
673                (i + 1).min(self.len)
674            }
675            None => {
676                // offset < prefix(0), so offset is within item 0
677                0
678            }
679        }
680    }
681
682    /// Count how many items are visible within a viewport starting at `start_idx`. O(log n).
683    ///
684    /// Returns the number of items that fit completely within `viewport_height`,
685    /// except that it returns **at least 1** when `start_idx < len` and
686    /// `viewport_height > 0`, even if the first item is taller than the viewport.
687    #[must_use]
688    pub fn visible_count(&self, start_idx: usize, viewport_height: u16) -> usize {
689        if self.len == 0 || viewport_height == 0 {
690            return 0;
691        }
692        let start = start_idx.min(self.len);
693        let start_offset = self.offset_of_item(start);
694        let end_offset = start_offset.saturating_add(u32::from(viewport_height));
695
696        // Find last item that fits
697        let end_idx = self.find_item_at_offset(end_offset);
698
699        // Count items from start to end (exclusive of partially visible)
700        if end_idx > start {
701            // `find_item_at_offset` returns `len` when `end_offset` is at/after the end
702            // of the list. In that case, everything from `start` to the end fits.
703            if end_idx >= self.len {
704                return self.len.saturating_sub(start);
705            }
706            // Check if end_idx item is fully visible
707            let end_item_start = self.offset_of_item(end_idx);
708            if end_item_start.saturating_add(u32::from(self.get(end_idx))) <= end_offset {
709                end_idx - start + 1
710            } else {
711                end_idx - start
712            }
713        } else {
714            // At least show one item if viewport has space
715            if viewport_height > 0 && start < self.len {
716                1
717            } else {
718                0
719            }
720        }
721    }
722
723    /// Get total height of all items. O(log n).
724    #[must_use]
725    pub fn total_height(&self) -> u32 {
726        self.tree.total()
727    }
728
729    /// Resize the tracker to accommodate `new_len` items.
730    ///
731    /// New items are initialized with default height.
732    pub fn resize(&mut self, new_len: usize) {
733        if new_len == self.len {
734            return;
735        }
736        self.tree.resize(new_len);
737        // Set default heights for new items
738        if new_len > self.len {
739            for i in self.len..new_len {
740                self.tree.set(i, u32::from(self.default_height));
741            }
742        }
743        self.len = new_len;
744    }
745
746    /// Clear all height data.
747    pub fn clear(&mut self) {
748        self.tree = FenwickTree::new(0);
749        self.len = 0;
750    }
751
752    /// Rebuild from a fresh set of heights.
753    pub fn rebuild(&mut self, heights: &[u16]) {
754        let heights_u32: Vec<u32> = heights.iter().map(|&h| u32::from(h)).collect();
755        self.tree = FenwickTree::from_values(&heights_u32);
756        self.len = heights.len();
757    }
758}
759
760// ============================================================================
761// VirtualizedList Widget
762// ============================================================================
763
764/// Trait for items that can render themselves.
765///
766/// Implement this trait for item types that should render in a `VirtualizedList`.
767pub trait RenderItem {
768    /// Render the item into the frame at the given area.
769    fn render(&self, area: Rect, frame: &mut Frame, selected: bool);
770
771    /// Height of this item in terminal rows.
772    fn height(&self) -> u16 {
773        1
774    }
775}
776
777/// State for the VirtualizedList widget.
778#[derive(Debug, Clone)]
779pub struct VirtualizedListState {
780    /// Currently selected index.
781    pub selected: Option<usize>,
782    /// Scroll offset.
783    scroll_offset: usize,
784    /// Visible count (from last render).
785    visible_count: usize,
786    /// Overscan amount.
787    overscan: usize,
788    /// Whether follow mode is enabled.
789    follow_mode: bool,
790    /// Scroll velocity for momentum.
791    scroll_velocity: f32,
792    /// Optional persistence ID for state saving/restoration.
793    persistence_id: Option<String>,
794}
795
796impl Default for VirtualizedListState {
797    fn default() -> Self {
798        Self::new()
799    }
800}
801
802impl VirtualizedListState {
803    /// Create a new state.
804    #[must_use]
805    pub fn new() -> Self {
806        Self {
807            selected: None,
808            scroll_offset: 0,
809            visible_count: 0,
810            overscan: 2,
811            follow_mode: false,
812            scroll_velocity: 0.0,
813            persistence_id: None,
814        }
815    }
816
817    /// Create with overscan.
818    #[must_use]
819    pub fn with_overscan(mut self, overscan: usize) -> Self {
820        self.overscan = overscan;
821        self
822    }
823
824    /// Create with follow mode enabled.
825    #[must_use]
826    pub fn with_follow(mut self, follow: bool) -> Self {
827        self.follow_mode = follow;
828        self
829    }
830
831    /// Create with a persistence ID for state saving.
832    #[must_use]
833    pub fn with_persistence_id(mut self, id: impl Into<String>) -> Self {
834        self.persistence_id = Some(id.into());
835        self
836    }
837
838    /// Get the persistence ID, if set.
839    #[must_use = "use the persistence id (if any)"]
840    pub fn persistence_id(&self) -> Option<&str> {
841        self.persistence_id.as_deref()
842    }
843
844    /// Get current scroll offset.
845    #[must_use]
846    pub fn scroll_offset(&self) -> usize {
847        self.scroll_offset
848    }
849
850    /// Get visible item count (from last render).
851    #[must_use]
852    pub fn visible_count(&self) -> usize {
853        self.visible_count
854    }
855
856    /// Scroll by delta (positive = down).
857    pub fn scroll(&mut self, delta: i32, total_items: usize) {
858        if total_items == 0 {
859            return;
860        }
861        let max_offset = if self.visible_count > 0 {
862            total_items.saturating_sub(self.visible_count)
863        } else {
864            total_items.saturating_sub(1)
865        };
866        let new_offset = (self.scroll_offset as i64 + delta as i64)
867            .max(0)
868            .min(max_offset as i64);
869        self.scroll_offset = new_offset as usize;
870
871        if delta != 0 {
872            self.follow_mode = false;
873        }
874    }
875
876    /// Scroll to specific index.
877    pub fn scroll_to(&mut self, idx: usize, total_items: usize) {
878        self.scroll_offset = idx.min(total_items.saturating_sub(1));
879        self.follow_mode = false;
880    }
881
882    /// Scroll to top.
883    pub fn scroll_to_top(&mut self) {
884        self.scroll_offset = 0;
885        self.follow_mode = false;
886    }
887
888    /// Scroll to bottom.
889    pub fn scroll_to_bottom(&mut self, total_items: usize) {
890        if total_items > self.visible_count && self.visible_count > 0 {
891            self.scroll_offset = total_items - self.visible_count;
892        } else {
893            self.scroll_offset = 0;
894        }
895    }
896
897    /// Page up (scroll by visible count).
898    pub fn page_up(&mut self, total_items: usize) {
899        if self.visible_count > 0 {
900            let delta = i32::try_from(self.visible_count).unwrap_or(i32::MAX);
901            self.scroll(-delta, total_items);
902        }
903    }
904
905    /// Page down (scroll by visible count).
906    pub fn page_down(&mut self, total_items: usize) {
907        if self.visible_count > 0 {
908            let delta = i32::try_from(self.visible_count).unwrap_or(i32::MAX);
909            self.scroll(delta, total_items);
910        }
911    }
912
913    /// Select an item.
914    pub fn select(&mut self, index: Option<usize>) {
915        self.selected = index;
916    }
917
918    /// Select previous item.
919    pub fn select_previous(&mut self, total_items: usize) {
920        if total_items == 0 {
921            self.selected = None;
922            return;
923        }
924        self.selected = Some(match self.selected {
925            Some(i) if i > 0 => i - 1,
926            Some(_) => 0,
927            None => 0,
928        });
929    }
930
931    /// Select next item.
932    pub fn select_next(&mut self, total_items: usize) {
933        if total_items == 0 {
934            self.selected = None;
935            return;
936        }
937        self.selected = Some(match self.selected {
938            Some(i) if i < total_items - 1 => i + 1,
939            Some(i) => i,
940            None => 0,
941        });
942    }
943
944    /// Check if at bottom.
945    #[must_use]
946    pub fn is_at_bottom(&self, total_items: usize) -> bool {
947        if total_items <= self.visible_count {
948            true
949        } else {
950            self.scroll_offset >= total_items - self.visible_count
951        }
952    }
953
954    /// Enable/disable follow mode.
955    pub fn set_follow(&mut self, follow: bool, total_items: usize) {
956        self.follow_mode = follow;
957        if follow {
958            self.scroll_to_bottom(total_items);
959        }
960    }
961
962    /// Check if follow mode is enabled.
963    #[must_use]
964    pub fn follow_mode(&self) -> bool {
965        self.follow_mode
966    }
967
968    /// Start momentum scroll.
969    pub fn fling(&mut self, velocity: f32) {
970        self.scroll_velocity = velocity;
971    }
972
973    /// Apply momentum scrolling tick.
974    pub fn tick(&mut self, dt: Duration, total_items: usize) {
975        if self.scroll_velocity.abs() > 0.1 {
976            let delta = (self.scroll_velocity * dt.as_secs_f32()) as i32;
977            if delta != 0 {
978                self.scroll(delta, total_items);
979            }
980            self.scroll_velocity *= 0.95;
981        } else {
982            self.scroll_velocity = 0.0;
983        }
984    }
985}
986
987// ============================================================================
988// Stateful Persistence Implementation for VirtualizedListState
989// ============================================================================
990
991/// Persistable state for a [`VirtualizedListState`].
992///
993/// Contains the user-facing scroll state that should survive sessions.
994/// Transient values like scroll_velocity and visible_count are not persisted.
995#[derive(Clone, Debug, Default, PartialEq)]
996#[cfg_attr(
997    feature = "state-persistence",
998    derive(serde::Serialize, serde::Deserialize)
999)]
1000pub struct VirtualizedListPersistState {
1001    /// Selected item index.
1002    pub selected: Option<usize>,
1003    /// Scroll offset (first visible item).
1004    pub scroll_offset: usize,
1005    /// Whether follow mode is enabled.
1006    pub follow_mode: bool,
1007}
1008
1009impl crate::stateful::Stateful for VirtualizedListState {
1010    type State = VirtualizedListPersistState;
1011
1012    fn state_key(&self) -> crate::stateful::StateKey {
1013        crate::stateful::StateKey::new(
1014            "VirtualizedList",
1015            self.persistence_id.as_deref().unwrap_or("default"),
1016        )
1017    }
1018
1019    fn save_state(&self) -> VirtualizedListPersistState {
1020        VirtualizedListPersistState {
1021            selected: self.selected,
1022            scroll_offset: self.scroll_offset,
1023            follow_mode: self.follow_mode,
1024        }
1025    }
1026
1027    fn restore_state(&mut self, state: VirtualizedListPersistState) {
1028        self.selected = state.selected;
1029        self.scroll_offset = state.scroll_offset;
1030        self.follow_mode = state.follow_mode;
1031        // Reset transient values
1032        self.scroll_velocity = 0.0;
1033    }
1034}
1035
1036/// A virtualized list widget that renders only visible items.
1037///
1038/// This widget efficiently renders large lists by only drawing items
1039/// that are currently visible in the viewport, with optional overscan
1040/// for smooth scrolling.
1041#[derive(Debug)]
1042pub struct VirtualizedList<'a, T> {
1043    /// Items to render.
1044    items: &'a [T],
1045    /// Base style.
1046    style: Style,
1047    /// Style for selected item.
1048    highlight_style: Style,
1049    /// Whether to show scrollbar.
1050    show_scrollbar: bool,
1051    /// Fixed item height.
1052    fixed_height: u16,
1053}
1054
1055impl<'a, T> VirtualizedList<'a, T> {
1056    /// Create a new virtualized list.
1057    #[must_use]
1058    pub fn new(items: &'a [T]) -> Self {
1059        Self {
1060            items,
1061            style: Style::default(),
1062            highlight_style: Style::default(),
1063            show_scrollbar: true,
1064            fixed_height: 1,
1065        }
1066    }
1067
1068    /// Set base style.
1069    #[must_use]
1070    pub fn style(mut self, style: Style) -> Self {
1071        self.style = style;
1072        self
1073    }
1074
1075    /// Set highlight style for selected item.
1076    #[must_use]
1077    pub fn highlight_style(mut self, style: Style) -> Self {
1078        self.highlight_style = style;
1079        self
1080    }
1081
1082    /// Enable/disable scrollbar.
1083    #[must_use]
1084    pub fn show_scrollbar(mut self, show: bool) -> Self {
1085        self.show_scrollbar = show;
1086        self
1087    }
1088
1089    /// Set fixed item height.
1090    #[must_use]
1091    pub fn fixed_height(mut self, height: u16) -> Self {
1092        self.fixed_height = height;
1093        self
1094    }
1095}
1096
1097impl<T: RenderItem> StatefulWidget for VirtualizedList<'_, T> {
1098    type State = VirtualizedListState;
1099
1100    fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
1101        #[cfg(feature = "tracing")]
1102        let _span = tracing::debug_span!(
1103            "widget_render",
1104            widget = "VirtualizedList",
1105            x = area.x,
1106            y = area.y,
1107            w = area.width,
1108            h = area.height,
1109            items = self.items.len()
1110        )
1111        .entered();
1112
1113        if area.is_empty() {
1114            return;
1115        }
1116
1117        // Apply base style
1118        set_style_area(&mut frame.buffer, area, self.style);
1119
1120        let total_items = self.items.len();
1121        if total_items == 0 {
1122            return;
1123        }
1124
1125        // Reserve space for scrollbar if needed
1126        let items_per_viewport = (area.height / self.fixed_height.max(1)) as usize;
1127        let needs_scrollbar = self.show_scrollbar && total_items > items_per_viewport;
1128        let content_width = if needs_scrollbar {
1129            area.width.saturating_sub(1)
1130        } else {
1131            area.width
1132        };
1133
1134        // Ensure selection is within bounds
1135        if let Some(selected) = state.selected
1136            && selected >= total_items
1137        {
1138            // Use saturating_sub to handle empty list case (total_items = 0)
1139            state.selected = if total_items > 0 {
1140                Some(total_items - 1)
1141            } else {
1142                None
1143            };
1144        }
1145
1146        // Ensure visible range includes selected item
1147        if let Some(selected) = state.selected {
1148            if selected >= state.scroll_offset + items_per_viewport {
1149                state.scroll_offset = selected.saturating_sub(items_per_viewport.saturating_sub(1));
1150            } else if selected < state.scroll_offset {
1151                state.scroll_offset = selected;
1152            }
1153        }
1154
1155        // Clamp scroll offset
1156        let max_offset = total_items.saturating_sub(items_per_viewport);
1157        state.scroll_offset = state.scroll_offset.min(max_offset);
1158
1159        // Update visible count
1160        state.visible_count = items_per_viewport.min(total_items);
1161
1162        // Calculate render range with overscan
1163        let render_start = state.scroll_offset.saturating_sub(state.overscan);
1164        let render_end = state
1165            .scroll_offset
1166            .saturating_add(items_per_viewport)
1167            .saturating_add(state.overscan)
1168            .min(total_items);
1169
1170        // Render visible items
1171        for idx in render_start..render_end {
1172            // Calculate Y position relative to viewport
1173            // Use saturating casts to prevent overflow with large item counts.
1174            let idx_i32 = i32::try_from(idx).unwrap_or(i32::MAX);
1175            let offset_i32 = i32::try_from(state.scroll_offset).unwrap_or(i32::MAX);
1176            let relative_idx = idx_i32.saturating_sub(offset_i32);
1177            let height_i32 = i32::from(self.fixed_height);
1178            let y_offset = relative_idx.saturating_mul(height_i32);
1179
1180            // Skip items above viewport
1181            if y_offset.saturating_add(height_i32) <= 0 {
1182                continue;
1183            }
1184
1185            // Stop if below viewport
1186            if y_offset >= i32::from(area.height) {
1187                break;
1188            }
1189
1190            // Check if item starts off-screen top (terminal y < 0)
1191            // We cannot render at negative coordinates, and clamping to 0 causes artifacts
1192            // (drawing top of item instead of bottom). Skip such items.
1193            if i32::from(area.y).saturating_add(y_offset) < 0 {
1194                continue;
1195            }
1196
1197            // Calculate actual render area
1198            // Use i32 arithmetic to avoid overflow when casting y_offset to i16
1199            let y = i32::from(area.y)
1200                .saturating_add(y_offset)
1201                .clamp(0, i32::from(u16::MAX)) as u16;
1202            if y >= area.bottom() {
1203                break;
1204            }
1205
1206            let visible_height = self.fixed_height.min(area.bottom().saturating_sub(y));
1207            if visible_height == 0 {
1208                continue;
1209            }
1210
1211            let row_area = Rect::new(area.x, y, content_width, visible_height);
1212
1213            let is_selected = state.selected == Some(idx);
1214
1215            // Apply highlight style to selected row
1216            if is_selected {
1217                set_style_area(&mut frame.buffer, row_area, self.highlight_style);
1218            }
1219
1220            // Render the item
1221            self.items[idx].render(row_area, frame, is_selected);
1222        }
1223
1224        // Render scrollbar
1225        if needs_scrollbar {
1226            let scrollbar_area = Rect::new(area.right().saturating_sub(1), area.y, 1, area.height);
1227
1228            let mut scrollbar_state =
1229                ScrollbarState::new(total_items, state.scroll_offset, items_per_viewport);
1230
1231            let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight);
1232            scrollbar.render(scrollbar_area, frame, &mut scrollbar_state);
1233        }
1234    }
1235}
1236
1237// ============================================================================
1238// Simple RenderItem implementations for common types
1239// ============================================================================
1240
1241impl RenderItem for String {
1242    fn render(&self, area: Rect, frame: &mut Frame, _selected: bool) {
1243        if area.is_empty() {
1244            return;
1245        }
1246        let max_chars = area.width as usize;
1247        for (i, ch) in self.chars().take(max_chars).enumerate() {
1248            frame
1249                .buffer
1250                .set(area.x.saturating_add(i as u16), area.y, Cell::from_char(ch));
1251        }
1252    }
1253}
1254
1255impl RenderItem for &str {
1256    fn render(&self, area: Rect, frame: &mut Frame, _selected: bool) {
1257        if area.is_empty() {
1258            return;
1259        }
1260        let max_chars = area.width as usize;
1261        for (i, ch) in self.chars().take(max_chars).enumerate() {
1262            frame
1263                .buffer
1264                .set(area.x.saturating_add(i as u16), area.y, Cell::from_char(ch));
1265        }
1266    }
1267}
1268
1269#[cfg(test)]
1270mod tests {
1271    use super::*;
1272
1273    #[test]
1274    fn test_new_virtualized() {
1275        let virt: Virtualized<String> = Virtualized::new(100);
1276        assert_eq!(virt.len(), 0);
1277        assert!(virt.is_empty());
1278    }
1279
1280    #[test]
1281    fn test_push_and_len() {
1282        let mut virt: Virtualized<i32> = Virtualized::new(100);
1283        virt.push(1);
1284        virt.push(2);
1285        virt.push(3);
1286        assert_eq!(virt.len(), 3);
1287        assert!(!virt.is_empty());
1288    }
1289
1290    #[test]
1291    fn test_visible_range_fixed_height() {
1292        let mut virt: Virtualized<i32> = Virtualized::new(100).with_fixed_height(2);
1293        for i in 0..20 {
1294            virt.push(i);
1295        }
1296        // 10 items visible with height 2 in viewport 20
1297        let range = virt.visible_range(20);
1298        assert_eq!(range, 0..10);
1299    }
1300
1301    #[test]
1302    fn test_visible_range_variable_height_clamps() {
1303        let mut cache = HeightCache::new(1, 16);
1304        cache.set(0, 3);
1305        cache.set(1, 3);
1306        cache.set(2, 3);
1307        let mut virt: Virtualized<i32> =
1308            Virtualized::new(10).with_item_height(ItemHeight::Variable(cache));
1309        for i in 0..3 {
1310            virt.push(i);
1311        }
1312        let range = virt.visible_range(5);
1313        assert_eq!(range, 0..1);
1314    }
1315
1316    #[test]
1317    fn test_visible_range_variable_height_exact_fit() {
1318        let mut cache = HeightCache::new(1, 16);
1319        cache.set(0, 2);
1320        cache.set(1, 3);
1321        let mut virt: Virtualized<i32> =
1322            Virtualized::new(10).with_item_height(ItemHeight::Variable(cache));
1323        for i in 0..2 {
1324            virt.push(i);
1325        }
1326        let range = virt.visible_range(5);
1327        assert_eq!(range, 0..2);
1328    }
1329
1330    #[test]
1331    fn test_visible_range_with_scroll() {
1332        let mut virt: Virtualized<i32> = Virtualized::new(100).with_fixed_height(1);
1333        for i in 0..50 {
1334            virt.push(i);
1335        }
1336        virt.scroll(10);
1337        let range = virt.visible_range(10);
1338        assert_eq!(range, 10..20);
1339    }
1340
1341    #[test]
1342    fn test_visible_range_variable_height_excludes_partial() {
1343        let mut cache = HeightCache::new(1, 16);
1344        cache.set(0, 6);
1345        cache.set(1, 6);
1346        let mut virt: Virtualized<i32> =
1347            Virtualized::new(100).with_item_height(ItemHeight::Variable(cache));
1348        virt.push(1);
1349        virt.push(2);
1350        virt.push(3);
1351
1352        let range = virt.visible_range(10);
1353        assert_eq!(range, 0..1);
1354    }
1355
1356    #[test]
1357    fn test_visible_range_variable_height_exact_fit_larger() {
1358        let mut cache = HeightCache::new(1, 16);
1359        cache.set(0, 4);
1360        cache.set(1, 6);
1361        let mut virt: Virtualized<i32> =
1362            Virtualized::new(100).with_item_height(ItemHeight::Variable(cache));
1363        virt.push(1);
1364        virt.push(2);
1365        virt.push(3);
1366
1367        let range = virt.visible_range(10);
1368        assert_eq!(range, 0..2);
1369    }
1370
1371    #[test]
1372    fn test_visible_range_variable_height_default_for_unmeasured() {
1373        let cache = HeightCache::new(2, 16);
1374        let mut virt: Virtualized<i32> =
1375            Virtualized::new(10).with_item_height(ItemHeight::Variable(cache));
1376        for i in 0..3 {
1377            virt.push(i);
1378        }
1379
1380        // Default height = 2, viewport 5 fits 2 items (2 + 2) but not the third.
1381        let range = virt.visible_range(5);
1382        assert_eq!(range, 0..2);
1383    }
1384
1385    #[test]
1386    fn test_render_range_with_overscan() {
1387        let mut virt: Virtualized<i32> =
1388            Virtualized::new(100).with_fixed_height(1).with_overscan(2);
1389        for i in 0..50 {
1390            virt.push(i);
1391        }
1392        virt.scroll(10);
1393        let range = virt.render_range(10);
1394        // Visible: 10..20, Overscan: 2
1395        // Render: 8..22
1396        assert_eq!(range, 8..22);
1397    }
1398
1399    #[test]
1400    fn test_scroll_bounds() {
1401        let mut virt: Virtualized<i32> = Virtualized::new(100);
1402        for i in 0..10 {
1403            virt.push(i);
1404        }
1405
1406        // Can't scroll negative
1407        virt.scroll(-100);
1408        assert_eq!(virt.scroll_offset(), 0);
1409
1410        // Can't scroll past end
1411        virt.scroll(100);
1412        assert_eq!(virt.scroll_offset(), 9);
1413    }
1414
1415    #[test]
1416    fn test_scroll_to() {
1417        let mut virt: Virtualized<i32> = Virtualized::new(100);
1418        for i in 0..20 {
1419            virt.push(i);
1420        }
1421
1422        virt.scroll_to(15);
1423        assert_eq!(virt.scroll_offset(), 15);
1424
1425        // Clamps to max
1426        virt.scroll_to(100);
1427        assert_eq!(virt.scroll_offset(), 19);
1428    }
1429
1430    #[test]
1431    fn test_follow_mode() {
1432        let mut virt: Virtualized<i32> = Virtualized::new(100).with_follow(true);
1433        virt.set_visible_count(5);
1434
1435        for i in 0..10 {
1436            virt.push(i);
1437        }
1438
1439        // Should be at bottom
1440        assert!(virt.is_at_bottom());
1441
1442        // Manual scroll disables follow
1443        virt.scroll(-5);
1444        assert!(!virt.follow_mode());
1445    }
1446
1447    #[test]
1448    fn test_scroll_to_start_and_end() {
1449        let mut virt: Virtualized<i32> = Virtualized::new(100);
1450        virt.set_visible_count(5);
1451        for i in 0..20 {
1452            virt.push(i);
1453        }
1454
1455        // scroll_to_start goes to top and disables follow
1456        virt.scroll_to(10);
1457        virt.set_follow(true);
1458        virt.scroll_to_start();
1459        assert_eq!(virt.scroll_offset(), 0);
1460        assert!(!virt.follow_mode());
1461
1462        // scroll_to_end goes to bottom and enables follow
1463        virt.scroll_to_end();
1464        assert!(virt.is_at_bottom());
1465        assert!(virt.follow_mode());
1466    }
1467
1468    #[test]
1469    fn test_virtualized_page_navigation() {
1470        let mut virt: Virtualized<i32> = Virtualized::new(100);
1471        virt.set_visible_count(5);
1472        for i in 0..30 {
1473            virt.push(i);
1474        }
1475
1476        virt.scroll_to(15);
1477        virt.page_up();
1478        assert_eq!(virt.scroll_offset(), 10);
1479
1480        virt.page_down();
1481        assert_eq!(virt.scroll_offset(), 15);
1482
1483        // Page up at start clamps to 0
1484        virt.scroll_to(2);
1485        virt.page_up();
1486        assert_eq!(virt.scroll_offset(), 0);
1487    }
1488
1489    #[test]
1490    fn test_height_cache() {
1491        let mut cache = HeightCache::new(1, 100);
1492
1493        // Default value
1494        assert_eq!(cache.get(0), 1);
1495        assert_eq!(cache.get(50), 1);
1496
1497        // Set value
1498        cache.set(5, 3);
1499        assert_eq!(cache.get(5), 3);
1500
1501        // Other indices still default
1502        assert_eq!(cache.get(4), 1);
1503        assert_eq!(cache.get(6), 1);
1504    }
1505
1506    #[test]
1507    fn test_height_cache_large_index_window() {
1508        let mut cache = HeightCache::new(1, 8);
1509        cache.set(10_000, 4);
1510        assert_eq!(cache.get(10_000), 4);
1511        assert_eq!(cache.get(0), 1);
1512        assert!(cache.cache.len() <= cache.capacity);
1513    }
1514
1515    #[test]
1516    fn test_clear() {
1517        let mut virt: Virtualized<i32> = Virtualized::new(100);
1518        for i in 0..10 {
1519            virt.push(i);
1520        }
1521        virt.scroll(5);
1522
1523        virt.clear();
1524        assert_eq!(virt.len(), 0);
1525        assert_eq!(virt.scroll_offset(), 0);
1526    }
1527
1528    #[test]
1529    fn test_get_item() {
1530        let mut virt: Virtualized<String> = Virtualized::new(100);
1531        virt.push("hello".to_string());
1532        virt.push("world".to_string());
1533
1534        assert_eq!(virt.get(0), Some(&"hello".to_string()));
1535        assert_eq!(virt.get(1), Some(&"world".to_string()));
1536        assert_eq!(virt.get(2), None);
1537    }
1538
1539    #[test]
1540    fn test_external_storage_len() {
1541        let mut virt: Virtualized<i32> = Virtualized::external(1000, 100);
1542        assert_eq!(virt.len(), 1000);
1543
1544        virt.set_external_len(2000);
1545        assert_eq!(virt.len(), 2000);
1546    }
1547
1548    #[test]
1549    fn test_momentum_scrolling() {
1550        let mut virt: Virtualized<i32> = Virtualized::new(100);
1551        for i in 0..50 {
1552            virt.push(i);
1553        }
1554
1555        virt.fling(10.0);
1556
1557        // Simulate tick
1558        virt.tick(Duration::from_millis(100));
1559
1560        // Should have scrolled
1561        assert!(virt.scroll_offset() > 0);
1562    }
1563
1564    // ========================================================================
1565    // VirtualizedListState tests
1566    // ========================================================================
1567
1568    #[test]
1569    fn test_virtualized_list_state_new() {
1570        let state = VirtualizedListState::new();
1571        assert_eq!(state.selected, None);
1572        assert_eq!(state.scroll_offset(), 0);
1573        assert_eq!(state.visible_count(), 0);
1574    }
1575
1576    #[test]
1577    fn test_virtualized_list_state_select_next() {
1578        let mut state = VirtualizedListState::new();
1579
1580        state.select_next(10);
1581        assert_eq!(state.selected, Some(0));
1582
1583        state.select_next(10);
1584        assert_eq!(state.selected, Some(1));
1585
1586        // At last item, stays there
1587        state.selected = Some(9);
1588        state.select_next(10);
1589        assert_eq!(state.selected, Some(9));
1590    }
1591
1592    #[test]
1593    fn test_virtualized_list_state_select_previous() {
1594        let mut state = VirtualizedListState::new();
1595        state.selected = Some(5);
1596
1597        state.select_previous(10);
1598        assert_eq!(state.selected, Some(4));
1599
1600        state.selected = Some(0);
1601        state.select_previous(10);
1602        assert_eq!(state.selected, Some(0));
1603    }
1604
1605    #[test]
1606    fn test_virtualized_list_state_scroll() {
1607        let mut state = VirtualizedListState::new();
1608
1609        state.scroll(5, 20);
1610        assert_eq!(state.scroll_offset(), 5);
1611
1612        state.scroll(-3, 20);
1613        assert_eq!(state.scroll_offset(), 2);
1614
1615        // Can't scroll negative
1616        state.scroll(-100, 20);
1617        assert_eq!(state.scroll_offset(), 0);
1618
1619        // Can't scroll past end
1620        state.scroll(100, 20);
1621        assert_eq!(state.scroll_offset(), 19);
1622    }
1623
1624    #[test]
1625    fn test_virtualized_list_state_follow_mode() {
1626        let mut state = VirtualizedListState::new().with_follow(true);
1627        assert!(state.follow_mode());
1628
1629        // Manual scroll disables follow
1630        state.scroll(5, 20);
1631        assert!(!state.follow_mode());
1632    }
1633
1634    #[test]
1635    fn test_render_item_string() {
1636        // Verify String implements RenderItem
1637        let s = String::from("hello");
1638        assert_eq!(s.height(), 1);
1639    }
1640
1641    #[test]
1642    fn test_page_up_down() {
1643        let mut virt: Virtualized<i32> = Virtualized::new(100);
1644        for i in 0..50 {
1645            virt.push(i);
1646        }
1647        virt.set_visible_count(10);
1648
1649        // Start at top
1650        assert_eq!(virt.scroll_offset(), 0);
1651
1652        // Page down
1653        virt.page_down();
1654        assert_eq!(virt.scroll_offset(), 10);
1655
1656        // Page down again
1657        virt.page_down();
1658        assert_eq!(virt.scroll_offset(), 20);
1659
1660        // Page up
1661        virt.page_up();
1662        assert_eq!(virt.scroll_offset(), 10);
1663
1664        // Page up again
1665        virt.page_up();
1666        assert_eq!(virt.scroll_offset(), 0);
1667
1668        // Page up at top stays at 0
1669        virt.page_up();
1670        assert_eq!(virt.scroll_offset(), 0);
1671    }
1672
1673    // ========================================================================
1674    // Performance invariant tests (bd-uo6v)
1675    // ========================================================================
1676
1677    #[test]
1678    fn test_render_scales_with_visible_not_total() {
1679        use ftui_render::grapheme_pool::GraphemePool;
1680        use std::time::Instant;
1681
1682        // Setup: VirtualizedList with 1K items
1683        let small_items: Vec<String> = (0..1_000).map(|i| format!("Line {}", i)).collect();
1684        let small_list = VirtualizedList::new(&small_items);
1685        let mut small_state = VirtualizedListState::new();
1686
1687        let area = Rect::new(0, 0, 80, 24);
1688        let mut pool = GraphemePool::new();
1689        let mut frame = Frame::new(80, 24, &mut pool);
1690
1691        // Warm up
1692        small_list.render(area, &mut frame, &mut small_state);
1693
1694        let start = Instant::now();
1695        for _ in 0..100 {
1696            frame.buffer.clear();
1697            small_list.render(area, &mut frame, &mut small_state);
1698        }
1699        let small_time = start.elapsed();
1700
1701        // Setup: VirtualizedList with 100K items
1702        let large_items: Vec<String> = (0..100_000).map(|i| format!("Line {}", i)).collect();
1703        let large_list = VirtualizedList::new(&large_items);
1704        let mut large_state = VirtualizedListState::new();
1705
1706        // Warm up
1707        large_list.render(area, &mut frame, &mut large_state);
1708
1709        let start = Instant::now();
1710        for _ in 0..100 {
1711            frame.buffer.clear();
1712            large_list.render(area, &mut frame, &mut large_state);
1713        }
1714        let large_time = start.elapsed();
1715
1716        // 100K should be within 3x of 1K (both render ~24 items)
1717        assert!(
1718            large_time < small_time * 3,
1719            "Render does not scale O(visible): 1K={:?}, 100K={:?}",
1720            small_time,
1721            large_time
1722        );
1723    }
1724
1725    #[test]
1726    fn test_scroll_is_constant_time() {
1727        use std::time::Instant;
1728
1729        let mut small: Virtualized<i32> = Virtualized::new(1_000);
1730        for i in 0..1_000 {
1731            small.push(i);
1732        }
1733        small.set_visible_count(24);
1734
1735        let mut large: Virtualized<i32> = Virtualized::new(100_000);
1736        for i in 0..100_000 {
1737            large.push(i);
1738        }
1739        large.set_visible_count(24);
1740
1741        let iterations = 10_000;
1742
1743        let start = Instant::now();
1744        for _ in 0..iterations {
1745            small.scroll(1);
1746            small.scroll(-1);
1747        }
1748        let small_time = start.elapsed();
1749
1750        let start = Instant::now();
1751        for _ in 0..iterations {
1752            large.scroll(1);
1753            large.scroll(-1);
1754        }
1755        let large_time = start.elapsed();
1756
1757        // Should be within 3x (both are O(1) operations)
1758        assert!(
1759            large_time < small_time * 3,
1760            "Scroll is not O(1): 1K={:?}, 100K={:?}",
1761            small_time,
1762            large_time
1763        );
1764    }
1765
1766    #[test]
1767    fn render_partially_offscreen_top_skips_item() {
1768        use ftui_render::grapheme_pool::GraphemePool;
1769
1770        // Items with height 2, each rendering its index as a character
1771        struct IndexedItem(usize);
1772        impl RenderItem for IndexedItem {
1773            fn render(&self, area: Rect, frame: &mut Frame, _selected: bool) {
1774                let ch = char::from_digit(self.0 as u32, 10).unwrap();
1775                for y in area.y..area.bottom() {
1776                    frame.buffer.set(area.x, y, Cell::from_char(ch));
1777                }
1778            }
1779            fn height(&self) -> u16 {
1780                2
1781            }
1782        }
1783
1784        // Need 4+ items so scroll_offset=1 is valid:
1785        // items_per_viewport = 5/2 = 2, max_offset = 4-2 = 2
1786        let items = vec![
1787            IndexedItem(0),
1788            IndexedItem(1),
1789            IndexedItem(2),
1790            IndexedItem(3),
1791        ];
1792        let list = VirtualizedList::new(&items).fixed_height(2);
1793
1794        // Scroll so item 1 is at top, item 0 is in overscan (above viewport)
1795        let mut state = VirtualizedListState::new().with_overscan(1);
1796        state.scroll_offset = 1; // Item 1 is top visible. Item 0 is in overscan.
1797
1798        let mut pool = GraphemePool::new();
1799        let mut frame = Frame::new(10, 5, &mut pool);
1800
1801        // Render at y=0 (terminal top edge)
1802        list.render(Rect::new(0, 0, 10, 5), &mut frame, &mut state);
1803
1804        // With scroll_offset=1 and overscan=1:
1805        // - render_start = 1 - 1 = 0 (include item 0 in overscan)
1806        // - Item 0 would render at y_offset = (0-1)*2 = -2
1807        // - area.y + y_offset = 0 + (-2) = -2 < 0, so item 0 must be SKIPPED
1808        // - Item 1 renders at y_offset = (1-1)*2 = 0
1809        //
1810        // Row 0 should be '1' (from Item 1), NOT '0' (from Item 0 ghosting)
1811        let cell = frame.buffer.get(0, 0).unwrap();
1812        assert_eq!(cell.content.as_char(), Some('1'));
1813    }
1814
1815    #[test]
1816    fn render_bottom_boundary_clips_partial_item() {
1817        use ftui_render::grapheme_pool::GraphemePool;
1818
1819        struct IndexedItem(u16);
1820        impl RenderItem for IndexedItem {
1821            fn render(&self, area: Rect, frame: &mut Frame, _selected: bool) {
1822                let ch = char::from_digit(self.0 as u32, 10).unwrap();
1823                for y in area.y..area.bottom() {
1824                    frame.buffer.set(area.x, y, Cell::from_char(ch));
1825                }
1826            }
1827            fn height(&self) -> u16 {
1828                2
1829            }
1830        }
1831
1832        let items = vec![IndexedItem(0), IndexedItem(1), IndexedItem(2)];
1833        let list = VirtualizedList::new(&items)
1834            .fixed_height(2)
1835            .show_scrollbar(false);
1836        let mut state = VirtualizedListState::new();
1837
1838        let mut pool = GraphemePool::new();
1839        let mut frame = Frame::new(4, 4, &mut pool);
1840
1841        // Viewport height 3 means the second item is only partially visible.
1842        list.render(Rect::new(0, 0, 4, 3), &mut frame, &mut state);
1843
1844        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('0'));
1845        assert_eq!(frame.buffer.get(0, 1).unwrap().content.as_char(), Some('0'));
1846        assert_eq!(frame.buffer.get(0, 2).unwrap().content.as_char(), Some('1'));
1847        // Row outside the viewport should remain empty.
1848        assert_eq!(frame.buffer.get(0, 3).unwrap().content.as_char(), None);
1849    }
1850
1851    #[test]
1852    fn render_after_fling_advances_visible_rows() {
1853        use ftui_render::grapheme_pool::GraphemePool;
1854
1855        struct IndexedItem(u16);
1856        impl RenderItem for IndexedItem {
1857            fn render(&self, area: Rect, frame: &mut Frame, _selected: bool) {
1858                let ch = char::from_digit(self.0 as u32, 10).unwrap();
1859                for y in area.y..area.bottom() {
1860                    frame.buffer.set(area.x, y, Cell::from_char(ch));
1861                }
1862            }
1863        }
1864
1865        let items: Vec<IndexedItem> = (0..10).map(IndexedItem).collect();
1866        let list = VirtualizedList::new(&items)
1867            .fixed_height(1)
1868            .show_scrollbar(false);
1869        let mut state = VirtualizedListState::new();
1870
1871        let mut pool = GraphemePool::new();
1872        let mut frame = Frame::new(4, 3, &mut pool);
1873        let area = Rect::new(0, 0, 4, 3);
1874
1875        // Initial render establishes visible_count and baseline top row.
1876        list.render(area, &mut frame, &mut state);
1877        assert_eq!(state.scroll_offset(), 0);
1878        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('0'));
1879
1880        // Momentum scroll: 40.0 * 0.1s = 4 rows.
1881        state.fling(40.0);
1882        state.tick(Duration::from_millis(100), items.len());
1883        assert_eq!(state.scroll_offset(), 4);
1884
1885        frame.buffer.clear();
1886        list.render(area, &mut frame, &mut state);
1887        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('4'));
1888    }
1889
1890    #[test]
1891    fn test_memory_bounded_by_ring_capacity() {
1892        use crate::log_ring::LogRing;
1893
1894        let mut ring: LogRing<String> = LogRing::new(1_000);
1895
1896        // Add 100K items
1897        for i in 0..100_000 {
1898            ring.push(format!("Line {}", i));
1899        }
1900
1901        // Only 1K in memory
1902        assert_eq!(ring.len(), 1_000);
1903        assert_eq!(ring.total_count(), 100_000);
1904        assert_eq!(ring.first_index(), 99_000);
1905
1906        // Can still access recent items
1907        assert!(ring.get(99_999).is_some());
1908        assert!(ring.get(99_000).is_some());
1909        // Old items evicted
1910        assert!(ring.get(0).is_none());
1911        assert!(ring.get(98_999).is_none());
1912    }
1913
1914    #[test]
1915    fn test_visible_range_constant_regardless_of_total() {
1916        let mut small: Virtualized<i32> = Virtualized::new(100);
1917        for i in 0..100 {
1918            small.push(i);
1919        }
1920        let small_range = small.visible_range(24);
1921
1922        let mut large: Virtualized<i32> = Virtualized::new(100_000);
1923        for i in 0..100_000 {
1924            large.push(i);
1925        }
1926        let large_range = large.visible_range(24);
1927
1928        // Both should return exactly 24 visible items
1929        assert_eq!(small_range.end - small_range.start, 24);
1930        assert_eq!(large_range.end - large_range.start, 24);
1931    }
1932
1933    #[test]
1934    fn test_virtualized_list_state_page_up_down() {
1935        let mut state = VirtualizedListState::new();
1936        state.visible_count = 10;
1937
1938        // Page down
1939        state.page_down(50);
1940        assert_eq!(state.scroll_offset(), 10);
1941
1942        // Page down again
1943        state.page_down(50);
1944        assert_eq!(state.scroll_offset(), 20);
1945
1946        // Page up
1947        state.page_up(50);
1948        assert_eq!(state.scroll_offset(), 10);
1949
1950        // Page up again
1951        state.page_up(50);
1952        assert_eq!(state.scroll_offset(), 0);
1953    }
1954
1955    // ========================================================================
1956    // VariableHeightsFenwick tests (bd-2zbk.7)
1957    // ========================================================================
1958
1959    #[test]
1960    fn test_variable_heights_fenwick_new() {
1961        let tracker = VariableHeightsFenwick::new(2, 10);
1962        assert_eq!(tracker.len(), 10);
1963        assert!(!tracker.is_empty());
1964        assert_eq!(tracker.default_height(), 2);
1965    }
1966
1967    #[test]
1968    fn test_variable_heights_fenwick_empty() {
1969        let tracker = VariableHeightsFenwick::new(1, 0);
1970        assert!(tracker.is_empty());
1971        assert_eq!(tracker.total_height(), 0);
1972    }
1973
1974    #[test]
1975    fn test_variable_heights_fenwick_from_heights() {
1976        let heights = vec![3, 2, 5, 1, 4];
1977        let tracker = VariableHeightsFenwick::from_heights(&heights, 1);
1978
1979        assert_eq!(tracker.len(), 5);
1980        assert_eq!(tracker.get(0), 3);
1981        assert_eq!(tracker.get(1), 2);
1982        assert_eq!(tracker.get(2), 5);
1983        assert_eq!(tracker.get(3), 1);
1984        assert_eq!(tracker.get(4), 4);
1985        assert_eq!(tracker.total_height(), 15);
1986    }
1987
1988    #[test]
1989    fn test_variable_heights_fenwick_offset_of_item() {
1990        // Heights: [3, 2, 5, 1, 4] -> offsets: [0, 3, 5, 10, 11]
1991        let heights = vec![3, 2, 5, 1, 4];
1992        let tracker = VariableHeightsFenwick::from_heights(&heights, 1);
1993
1994        assert_eq!(tracker.offset_of_item(0), 0);
1995        assert_eq!(tracker.offset_of_item(1), 3);
1996        assert_eq!(tracker.offset_of_item(2), 5);
1997        assert_eq!(tracker.offset_of_item(3), 10);
1998        assert_eq!(tracker.offset_of_item(4), 11);
1999        assert_eq!(tracker.offset_of_item(5), 15); // beyond end
2000    }
2001
2002    #[test]
2003    fn test_variable_heights_fenwick_find_item_at_offset() {
2004        // Heights: [3, 2, 5, 1, 4] -> cumulative: [3, 5, 10, 11, 15]
2005        let heights = vec![3, 2, 5, 1, 4];
2006        let tracker = VariableHeightsFenwick::from_heights(&heights, 1);
2007
2008        // Offset 0 should be item 0
2009        assert_eq!(tracker.find_item_at_offset(0), 0);
2010        // Offset 1 should be item 0 (within first item)
2011        assert_eq!(tracker.find_item_at_offset(1), 0);
2012        // Offset 3 should be item 1 (starts at offset 3)
2013        assert_eq!(tracker.find_item_at_offset(3), 1);
2014        // Offset 5 should be item 2
2015        assert_eq!(tracker.find_item_at_offset(5), 2);
2016        // Offset 10 should be item 3
2017        assert_eq!(tracker.find_item_at_offset(10), 3);
2018        // Offset 11 should be item 4
2019        assert_eq!(tracker.find_item_at_offset(11), 4);
2020        // Offset 15 should be end (beyond all items)
2021        assert_eq!(tracker.find_item_at_offset(15), 5);
2022    }
2023
2024    #[test]
2025    fn test_variable_heights_fenwick_visible_count() {
2026        // Heights: [3, 2, 5, 1, 4]
2027        let heights = vec![3, 2, 5, 1, 4];
2028        let tracker = VariableHeightsFenwick::from_heights(&heights, 1);
2029
2030        // Viewport 5: items 0 (h=3) + 1 (h=2) = 5 exactly
2031        assert_eq!(tracker.visible_count(0, 5), 2);
2032
2033        // Viewport 4: item 0 (h=3) fits, item 1 (h=2) doesn't fit fully
2034        assert_eq!(tracker.visible_count(0, 4), 1);
2035
2036        // Viewport 10: items 0+1+2 = 10 exactly
2037        assert_eq!(tracker.visible_count(0, 10), 3);
2038
2039        // From item 2, viewport 6: item 2 (h=5) + item 3 (h=1) = 6
2040        assert_eq!(tracker.visible_count(2, 6), 2);
2041    }
2042
2043    #[test]
2044    fn test_variable_heights_fenwick_visible_count_viewport_beyond_total_height() {
2045        let heights = vec![1, 1, 1];
2046        let tracker = VariableHeightsFenwick::from_heights(&heights, 1);
2047
2048        // Viewport extends past the end: must never overcount.
2049        assert_eq!(tracker.visible_count(0, 10), 3);
2050        assert_eq!(tracker.visible_count(1, 10), 2);
2051        assert_eq!(tracker.visible_count(2, 10), 1);
2052    }
2053
2054    #[test]
2055    fn test_variable_heights_fenwick_set() {
2056        let mut tracker = VariableHeightsFenwick::new(1, 5);
2057
2058        // All items should start with default height
2059        assert_eq!(tracker.get(0), 1);
2060        assert_eq!(tracker.total_height(), 5);
2061
2062        // Set item 2 to height 10
2063        tracker.set(2, 10);
2064        assert_eq!(tracker.get(2), 10);
2065        assert_eq!(tracker.total_height(), 14); // 1+1+10+1+1
2066    }
2067
2068    #[test]
2069    fn test_variable_heights_fenwick_resize() {
2070        let mut tracker = VariableHeightsFenwick::new(2, 3);
2071        assert_eq!(tracker.len(), 3);
2072        assert_eq!(tracker.total_height(), 6);
2073
2074        // Grow
2075        tracker.resize(5);
2076        assert_eq!(tracker.len(), 5);
2077        assert_eq!(tracker.total_height(), 10);
2078        assert_eq!(tracker.get(4), 2);
2079
2080        // Shrink
2081        tracker.resize(2);
2082        assert_eq!(tracker.len(), 2);
2083        assert_eq!(tracker.total_height(), 4);
2084    }
2085
2086    #[test]
2087    fn test_virtualized_with_variable_heights_fenwick() {
2088        let mut virt: Virtualized<i32> = Virtualized::new(100).with_variable_heights_fenwick(2, 10);
2089
2090        for i in 0..10 {
2091            virt.push(i);
2092        }
2093
2094        // All items height 2, viewport 6 -> 3 items visible
2095        let range = virt.visible_range(6);
2096        assert_eq!(range.end - range.start, 3);
2097    }
2098
2099    #[test]
2100    fn test_variable_heights_fenwick_performance() {
2101        use std::time::Instant;
2102
2103        // Create large tracker
2104        let n = 100_000;
2105        let heights: Vec<u16> = (0..n).map(|i| (i % 10 + 1) as u16).collect();
2106        let tracker = VariableHeightsFenwick::from_heights(&heights, 1);
2107
2108        // Warm up
2109        let _ = tracker.find_item_at_offset(500_000);
2110        let _ = tracker.offset_of_item(50_000);
2111
2112        // Benchmark find_item_at_offset (O(log n))
2113        let start = Instant::now();
2114        let mut _sink = 0usize;
2115        for i in 0..10_000 {
2116            _sink = _sink.wrapping_add(tracker.find_item_at_offset((i * 50) as u32));
2117        }
2118        let find_time = start.elapsed();
2119
2120        // Benchmark offset_of_item (O(log n))
2121        let start = Instant::now();
2122        let mut _sink2 = 0u32;
2123        for i in 0..10_000 {
2124            _sink2 = _sink2.wrapping_add(tracker.offset_of_item((i * 10) % n));
2125        }
2126        let offset_time = start.elapsed();
2127
2128        eprintln!("=== VariableHeightsFenwick Performance (n={n}) ===");
2129        eprintln!("10k find_item_at_offset: {:?}", find_time);
2130        eprintln!("10k offset_of_item:      {:?}", offset_time);
2131
2132        // Both should be under 50ms for 10k operations
2133        assert!(
2134            find_time < std::time::Duration::from_millis(50),
2135            "find_item_at_offset too slow: {:?}",
2136            find_time
2137        );
2138        assert!(
2139            offset_time < std::time::Duration::from_millis(50),
2140            "offset_of_item too slow: {:?}",
2141            offset_time
2142        );
2143    }
2144
2145    #[test]
2146    fn test_variable_heights_fenwick_scales_logarithmically() {
2147        use std::time::Instant;
2148
2149        // Small dataset
2150        let small_n = 1_000;
2151        let small_heights: Vec<u16> = (0..small_n).map(|i| (i % 5 + 1) as u16).collect();
2152        let small_tracker = VariableHeightsFenwick::from_heights(&small_heights, 1);
2153
2154        // Large dataset
2155        let large_n = 100_000;
2156        let large_heights: Vec<u16> = (0..large_n).map(|i| (i % 5 + 1) as u16).collect();
2157        let large_tracker = VariableHeightsFenwick::from_heights(&large_heights, 1);
2158
2159        let iterations = 5_000;
2160
2161        // Time small
2162        let start = Instant::now();
2163        for i in 0..iterations {
2164            let _ = small_tracker.find_item_at_offset((i * 2) as u32);
2165        }
2166        let small_time = start.elapsed();
2167
2168        // Time large
2169        let start = Instant::now();
2170        for i in 0..iterations {
2171            let _ = large_tracker.find_item_at_offset((i * 200) as u32);
2172        }
2173        let large_time = start.elapsed();
2174
2175        // Large should be within 5x of small (O(log n) vs O(n) would be 100x)
2176        assert!(
2177            large_time < small_time * 5,
2178            "Not O(log n): small={:?}, large={:?}",
2179            small_time,
2180            large_time
2181        );
2182    }
2183
2184    // ========================================================================
2185    // Edge-case tests (bd-2f15w)
2186    // ========================================================================
2187
2188    // ── Virtualized: construction & empty state ─────────────────────────
2189
2190    #[test]
2191    fn new_zero_capacity() {
2192        let virt: Virtualized<i32> = Virtualized::new(0);
2193        assert_eq!(virt.len(), 0);
2194        assert!(virt.is_empty());
2195        assert_eq!(virt.scroll_offset(), 0);
2196        assert_eq!(virt.visible_count(), 0);
2197        assert!(!virt.follow_mode());
2198    }
2199
2200    #[test]
2201    fn external_zero_len_zero_cache() {
2202        let virt: Virtualized<i32> = Virtualized::external(0, 0);
2203        assert_eq!(virt.len(), 0);
2204        assert!(virt.is_empty());
2205    }
2206
2207    #[test]
2208    fn external_storage_returns_none_for_get() {
2209        let virt: Virtualized<i32> = Virtualized::external(100, 10);
2210        assert_eq!(virt.get(0), None);
2211        assert_eq!(virt.get(50), None);
2212    }
2213
2214    #[test]
2215    fn external_storage_returns_none_for_get_mut() {
2216        let mut virt: Virtualized<i32> = Virtualized::external(100, 10);
2217        assert!(virt.get_mut(0).is_none());
2218    }
2219
2220    #[test]
2221    fn push_on_external_is_noop() {
2222        let mut virt: Virtualized<i32> = Virtualized::external(5, 10);
2223        virt.push(42);
2224        // Length unchanged because push only works on Owned
2225        assert_eq!(virt.len(), 5);
2226    }
2227
2228    #[test]
2229    fn iter_on_external_is_empty() {
2230        let virt: Virtualized<i32> = Virtualized::external(100, 10);
2231        assert_eq!(virt.iter().count(), 0);
2232    }
2233
2234    #[test]
2235    fn set_external_len_on_owned_is_noop() {
2236        let mut virt: Virtualized<i32> = Virtualized::new(100);
2237        virt.push(1);
2238        virt.set_external_len(999);
2239        assert_eq!(virt.len(), 1); // unchanged
2240    }
2241
2242    // ── Virtualized: visible_range edge cases ───────────────────────────
2243
2244    #[test]
2245    fn visible_range_zero_viewport() {
2246        let mut virt: Virtualized<i32> = Virtualized::new(100);
2247        virt.push(1);
2248        let range = virt.visible_range(0);
2249        assert_eq!(range, 0..0);
2250        assert_eq!(virt.visible_count(), 0);
2251    }
2252
2253    #[test]
2254    fn visible_range_empty_container() {
2255        let virt: Virtualized<i32> = Virtualized::new(100);
2256        let range = virt.visible_range(24);
2257        assert_eq!(range, 0..0);
2258    }
2259
2260    #[test]
2261    fn visible_range_fixed_height_zero() {
2262        // Fixed(0) should not divide by zero; falls through to viewport_height items
2263        let mut virt: Virtualized<i32> = Virtualized::new(100).with_fixed_height(0);
2264        for i in 0..10 {
2265            virt.push(i);
2266        }
2267        let range = virt.visible_range(5);
2268        // ItemHeight::Fixed(0) → viewport_height as usize = 5
2269        assert_eq!(range, 0..5);
2270    }
2271
2272    #[test]
2273    fn visible_range_fewer_items_than_viewport() {
2274        let mut virt: Virtualized<i32> = Virtualized::new(100);
2275        for i in 0..3 {
2276            virt.push(i);
2277        }
2278        let range = virt.visible_range(24);
2279        // Only 3 items, viewport fits 24
2280        assert_eq!(range, 0..3);
2281    }
2282
2283    #[test]
2284    fn visible_range_single_item() {
2285        let mut virt: Virtualized<i32> = Virtualized::new(100);
2286        virt.push(42);
2287        let range = virt.visible_range(1);
2288        assert_eq!(range, 0..1);
2289    }
2290
2291    // ── Virtualized: render_range edge cases ────────────────────────────
2292
2293    #[test]
2294    fn render_range_at_start_clamps_overscan() {
2295        let mut virt: Virtualized<i32> =
2296            Virtualized::new(100).with_fixed_height(1).with_overscan(5);
2297        for i in 0..20 {
2298            virt.push(i);
2299        }
2300        // At scroll_offset=0, start.saturating_sub(5) = 0
2301        let range = virt.render_range(10);
2302        assert_eq!(range.start, 0);
2303    }
2304
2305    #[test]
2306    fn render_range_at_end_clamps_overscan() {
2307        let mut virt: Virtualized<i32> =
2308            Virtualized::new(100).with_fixed_height(1).with_overscan(5);
2309        for i in 0..20 {
2310            virt.push(i);
2311        }
2312        virt.set_visible_count(10);
2313        virt.scroll_to(10); // offset=10, visible 10..20
2314        let range = virt.render_range(10);
2315        // end = min(20 + 5, 20) = 20
2316        assert_eq!(range.end, 20);
2317    }
2318
2319    #[test]
2320    fn render_range_zero_overscan() {
2321        let mut virt: Virtualized<i32> =
2322            Virtualized::new(100).with_fixed_height(1).with_overscan(0);
2323        for i in 0..20 {
2324            virt.push(i);
2325        }
2326        virt.set_visible_count(10);
2327        virt.scroll_to(5);
2328        let range = virt.render_range(10);
2329        // No overscan: render_range == visible_range
2330        let visible = virt.visible_range(10);
2331        assert_eq!(range, visible);
2332    }
2333
2334    // ── Virtualized: scroll edge cases ──────────────────────────────────
2335
2336    #[test]
2337    fn scroll_on_empty_is_noop() {
2338        let mut virt: Virtualized<i32> = Virtualized::new(100);
2339        virt.scroll(10);
2340        assert_eq!(virt.scroll_offset(), 0);
2341    }
2342
2343    #[test]
2344    fn scroll_delta_zero_does_not_disable_follow() {
2345        let mut virt: Virtualized<i32> = Virtualized::new(100).with_follow(true);
2346        virt.push(1);
2347        virt.scroll(0);
2348        // delta=0 doesn't disable follow_mode
2349        assert!(virt.follow_mode());
2350    }
2351
2352    #[test]
2353    fn scroll_negative_beyond_start() {
2354        let mut virt: Virtualized<i32> = Virtualized::new(100);
2355        for i in 0..10 {
2356            virt.push(i);
2357        }
2358        virt.scroll(-1);
2359        assert_eq!(virt.scroll_offset(), 0);
2360    }
2361
2362    #[test]
2363    fn scroll_to_on_empty() {
2364        let mut virt: Virtualized<i32> = Virtualized::new(100);
2365        // scroll_to on empty: idx.min(0.saturating_sub(1)) = idx.min(0) = 0
2366        virt.scroll_to(100);
2367        assert_eq!(virt.scroll_offset(), 0);
2368    }
2369
2370    #[test]
2371    fn scroll_to_top_already_at_top() {
2372        let mut virt: Virtualized<i32> = Virtualized::new(100);
2373        virt.push(1);
2374        virt.scroll_to_top();
2375        assert_eq!(virt.scroll_offset(), 0);
2376    }
2377
2378    #[test]
2379    fn scroll_to_bottom_fewer_items_than_visible() {
2380        let mut virt: Virtualized<i32> = Virtualized::new(100);
2381        virt.set_visible_count(10);
2382        for i in 0..3 {
2383            virt.push(i);
2384        }
2385        virt.scroll_to_bottom();
2386        // len (3) <= visible_count (10), so offset = 0
2387        assert_eq!(virt.scroll_offset(), 0);
2388    }
2389
2390    #[test]
2391    fn scroll_to_bottom_visible_count_zero() {
2392        let mut virt: Virtualized<i32> = Virtualized::new(100);
2393        for i in 0..20 {
2394            virt.push(i);
2395        }
2396        // visible_count=0 (default), scroll_to_bottom goes to offset=0
2397        virt.scroll_to_bottom();
2398        assert_eq!(virt.scroll_offset(), 0);
2399    }
2400
2401    // ── Virtualized: page navigation edge cases ─────────────────────────
2402
2403    #[test]
2404    fn page_up_visible_count_zero_is_noop() {
2405        let mut virt: Virtualized<i32> = Virtualized::new(100);
2406        for i in 0..20 {
2407            virt.push(i);
2408        }
2409        virt.scroll_to(10);
2410        // visible_count=0, page_up is no-op
2411        virt.page_up();
2412        assert_eq!(virt.scroll_offset(), 10);
2413    }
2414
2415    #[test]
2416    fn page_down_visible_count_zero_is_noop() {
2417        let mut virt: Virtualized<i32> = Virtualized::new(100);
2418        for i in 0..20 {
2419            virt.push(i);
2420        }
2421        // visible_count=0, page_down is no-op
2422        virt.page_down();
2423        assert_eq!(virt.scroll_offset(), 0);
2424    }
2425
2426    // ── Virtualized: is_at_bottom edge cases ────────────────────────────
2427
2428    #[test]
2429    fn is_at_bottom_fewer_items_than_visible() {
2430        let mut virt: Virtualized<i32> = Virtualized::new(100);
2431        virt.set_visible_count(10);
2432        for i in 0..3 {
2433            virt.push(i);
2434        }
2435        assert!(virt.is_at_bottom());
2436    }
2437
2438    #[test]
2439    fn is_at_bottom_empty() {
2440        let virt: Virtualized<i32> = Virtualized::new(100);
2441        // len=0 <= visible_count=0, so true
2442        assert!(virt.is_at_bottom());
2443    }
2444
2445    // ── Virtualized: trim_front edge cases ──────────────────────────────
2446
2447    #[test]
2448    fn trim_front_under_max_returns_zero() {
2449        let mut virt: Virtualized<i32> = Virtualized::new(100);
2450        for i in 0..5 {
2451            virt.push(i);
2452        }
2453        let removed = virt.trim_front(10);
2454        assert_eq!(removed, 0);
2455        assert_eq!(virt.len(), 5);
2456    }
2457
2458    #[test]
2459    fn trim_front_adjusts_scroll_offset() {
2460        let mut virt: Virtualized<i32> = Virtualized::new(100);
2461        for i in 0..20 {
2462            virt.push(i);
2463        }
2464        virt.scroll_to(10);
2465        let removed = virt.trim_front(15);
2466        assert_eq!(removed, 5);
2467        assert_eq!(virt.len(), 15);
2468        // scroll_offset adjusted: 10 - 5 = 5
2469        assert_eq!(virt.scroll_offset(), 5);
2470    }
2471
2472    #[test]
2473    fn trim_front_scroll_offset_saturates_to_zero() {
2474        let mut virt: Virtualized<i32> = Virtualized::new(100);
2475        for i in 0..20 {
2476            virt.push(i);
2477        }
2478        virt.scroll_to(2);
2479        let removed = virt.trim_front(10);
2480        assert_eq!(removed, 10);
2481        // scroll_offset 2 - 10 saturates to 0
2482        assert_eq!(virt.scroll_offset(), 0);
2483    }
2484
2485    #[test]
2486    fn trim_front_on_external_returns_zero() {
2487        let mut virt: Virtualized<i32> = Virtualized::external(100, 10);
2488        let removed = virt.trim_front(5);
2489        assert_eq!(removed, 0);
2490    }
2491
2492    // ── Virtualized: clear edge cases ───────────────────────────────────
2493
2494    #[test]
2495    fn clear_on_external_resets_scroll() {
2496        let mut virt: Virtualized<i32> = Virtualized::external(100, 10);
2497        virt.scroll_to(50);
2498        virt.clear();
2499        assert_eq!(virt.scroll_offset(), 0);
2500        // External len unchanged since clear only clears Owned
2501        assert_eq!(virt.len(), 100);
2502    }
2503
2504    // ── Virtualized: momentum scrolling edge cases ──────────────────────
2505
2506    #[test]
2507    fn tick_zero_velocity_is_noop() {
2508        let mut virt: Virtualized<i32> = Virtualized::new(100);
2509        for i in 0..20 {
2510            virt.push(i);
2511        }
2512        virt.tick(Duration::from_millis(100));
2513        assert_eq!(virt.scroll_offset(), 0);
2514    }
2515
2516    #[test]
2517    fn tick_below_threshold_stops_momentum() {
2518        let mut virt: Virtualized<i32> = Virtualized::new(100);
2519        for i in 0..20 {
2520            virt.push(i);
2521        }
2522        virt.fling(0.05); // below 0.1 threshold
2523        virt.tick(Duration::from_millis(100));
2524        // velocity <= 0.1, so it's zeroed out
2525        assert_eq!(virt.scroll_offset(), 0);
2526    }
2527
2528    #[test]
2529    fn tick_zero_duration_no_scroll() {
2530        let mut virt: Virtualized<i32> = Virtualized::new(100);
2531        for i in 0..50 {
2532            virt.push(i);
2533        }
2534        virt.fling(100.0);
2535        virt.tick(Duration::ZERO);
2536        // delta = (100.0 * 0.0) as i32 = 0, no scroll
2537        assert_eq!(virt.scroll_offset(), 0);
2538    }
2539
2540    #[test]
2541    fn fling_negative_scrolls_up() {
2542        let mut virt: Virtualized<i32> = Virtualized::new(100);
2543        for i in 0..50 {
2544            virt.push(i);
2545        }
2546        virt.scroll(20);
2547        let before = virt.scroll_offset();
2548        virt.fling(-50.0);
2549        virt.tick(Duration::from_millis(100));
2550        assert!(virt.scroll_offset() < before);
2551    }
2552
2553    // ── Virtualized: follow mode edge cases ─────────────────────────────
2554
2555    #[test]
2556    fn follow_mode_auto_scrolls_on_push() {
2557        let mut virt: Virtualized<i32> = Virtualized::new(100).with_follow(true);
2558        virt.set_visible_count(5);
2559        for i in 0..20 {
2560            virt.push(i);
2561        }
2562        // With follow mode, should be at bottom
2563        assert!(virt.is_at_bottom());
2564        assert_eq!(virt.scroll_offset(), 15); // 20 - 5
2565    }
2566
2567    #[test]
2568    fn set_follow_false_does_not_scroll() {
2569        let mut virt: Virtualized<i32> = Virtualized::new(100);
2570        virt.set_visible_count(5);
2571        for i in 0..20 {
2572            virt.push(i);
2573        }
2574        virt.scroll_to(5);
2575        virt.set_follow(false);
2576        assert_eq!(virt.scroll_offset(), 5); // unchanged
2577    }
2578
2579    #[test]
2580    fn scroll_to_start_disables_follow() {
2581        let mut virt: Virtualized<i32> = Virtualized::new(100).with_follow(true);
2582        virt.set_visible_count(5);
2583        for i in 0..20 {
2584            virt.push(i);
2585        }
2586        virt.scroll_to_start();
2587        assert!(!virt.follow_mode());
2588        assert_eq!(virt.scroll_offset(), 0);
2589    }
2590
2591    #[test]
2592    fn scroll_to_end_enables_follow() {
2593        let mut virt: Virtualized<i32> = Virtualized::new(100);
2594        virt.set_visible_count(5);
2595        for i in 0..20 {
2596            virt.push(i);
2597        }
2598        assert!(!virt.follow_mode());
2599        virt.scroll_to_end();
2600        assert!(virt.follow_mode());
2601        assert!(virt.is_at_bottom());
2602    }
2603
2604    #[test]
2605    fn external_follow_mode_scrolls_on_set_external_len() {
2606        let mut virt: Virtualized<i32> = Virtualized::external(10, 100).with_follow(true);
2607        virt.set_visible_count(5);
2608        virt.set_external_len(20);
2609        assert_eq!(virt.len(), 20);
2610        assert!(virt.is_at_bottom());
2611    }
2612
2613    // ── Virtualized: builder chain ──────────────────────────────────────
2614
2615    #[test]
2616    fn builder_chain_all_options() {
2617        let virt: Virtualized<i32> = Virtualized::new(100)
2618            .with_fixed_height(3)
2619            .with_overscan(5)
2620            .with_follow(true);
2621        assert!(virt.follow_mode());
2622        // Verify visible_range uses height=3
2623        // (no items, so empty range regardless)
2624        let range = virt.visible_range(9);
2625        assert_eq!(range, 0..0);
2626    }
2627
2628    // ── HeightCache edge cases ──────────────────────────────────────────
2629
2630    #[test]
2631    fn height_cache_default() {
2632        let cache = HeightCache::default();
2633        assert_eq!(cache.get(0), 1); // default_height=1
2634        assert_eq!(cache.capacity, 1000);
2635    }
2636
2637    #[test]
2638    fn height_cache_get_before_base_offset() {
2639        let mut cache = HeightCache::new(5, 100);
2640        // Set something to push base_offset forward
2641        cache.set(200, 10); // This resets window since 200 > capacity
2642        // Index 0 < base_offset, returns default
2643        assert_eq!(cache.get(0), 5);
2644    }
2645
2646    #[test]
2647    fn height_cache_set_before_base_offset_ignored() {
2648        let mut cache = HeightCache::new(5, 100);
2649        cache.set(200, 10);
2650        let base = cache.base_offset;
2651        cache.set(0, 99); // before base_offset, should be ignored
2652        assert_eq!(cache.get(0), 5); // still default
2653        assert_eq!(cache.base_offset, base); // unchanged
2654    }
2655
2656    #[test]
2657    fn height_cache_capacity_zero_ignores_all_sets() {
2658        let mut cache = HeightCache::new(3, 0);
2659        cache.set(0, 10);
2660        cache.set(5, 20);
2661        // Everything returns default since capacity=0
2662        assert_eq!(cache.get(0), 3);
2663        assert_eq!(cache.get(5), 3);
2664    }
2665
2666    #[test]
2667    fn height_cache_clear_resets_base() {
2668        let mut cache = HeightCache::new(1, 100);
2669        cache.set(50, 10);
2670        cache.clear();
2671        assert_eq!(cache.base_offset, 0);
2672        assert_eq!(cache.get(50), 1); // back to default
2673    }
2674
2675    #[test]
2676    fn height_cache_eviction_trims_oldest() {
2677        let mut cache = HeightCache::new(1, 4);
2678        // Set indices 0..6 to fill and trigger eviction
2679        for i in 0..6 {
2680            cache.set(i, (i + 10) as u16);
2681        }
2682        // Cache capacity=4, so indices 0-1 should be evicted
2683        assert!(cache.cache.len() <= cache.capacity);
2684        // Recent indices should be accessible
2685        assert_eq!(cache.get(5), 15);
2686        // Old indices return default
2687        assert_eq!(cache.get(0), 1);
2688    }
2689
2690    // ── VariableHeightsFenwick edge cases ───────────────────────────────
2691
2692    #[test]
2693    fn fenwick_default_is_empty() {
2694        let tracker = VariableHeightsFenwick::default();
2695        assert!(tracker.is_empty());
2696        assert_eq!(tracker.len(), 0);
2697        assert_eq!(tracker.total_height(), 0);
2698        assert_eq!(tracker.default_height(), 1);
2699    }
2700
2701    #[test]
2702    fn fenwick_get_beyond_len_returns_default() {
2703        let tracker = VariableHeightsFenwick::new(3, 5);
2704        assert_eq!(tracker.get(5), 3); // beyond len
2705        assert_eq!(tracker.get(100), 3);
2706    }
2707
2708    #[test]
2709    fn fenwick_set_beyond_len_resizes() {
2710        let mut tracker = VariableHeightsFenwick::new(2, 3);
2711        assert_eq!(tracker.len(), 3);
2712        tracker.set(10, 7);
2713        assert!(tracker.len() > 10);
2714        assert_eq!(tracker.get(10), 7);
2715    }
2716
2717    #[test]
2718    fn fenwick_offset_of_item_zero_always_zero() {
2719        let tracker = VariableHeightsFenwick::new(5, 10);
2720        assert_eq!(tracker.offset_of_item(0), 0);
2721
2722        let empty = VariableHeightsFenwick::new(5, 0);
2723        assert_eq!(empty.offset_of_item(0), 0);
2724    }
2725
2726    #[test]
2727    fn fenwick_find_item_at_offset_empty() {
2728        let tracker = VariableHeightsFenwick::new(1, 0);
2729        assert_eq!(tracker.find_item_at_offset(0), 0);
2730        assert_eq!(tracker.find_item_at_offset(100), 0);
2731    }
2732
2733    #[test]
2734    fn fenwick_visible_count_zero_viewport() {
2735        let tracker = VariableHeightsFenwick::new(2, 10);
2736        assert_eq!(tracker.visible_count(0, 0), 0);
2737    }
2738
2739    #[test]
2740    fn fenwick_visible_count_start_beyond_len() {
2741        let tracker = VariableHeightsFenwick::new(2, 5);
2742        // start_idx clamped to len
2743        let count = tracker.visible_count(100, 10);
2744        // start=5 (clamped), offset=total, no items visible
2745        assert_eq!(count, 0);
2746    }
2747
2748    #[test]
2749    fn fenwick_clear_then_operations() {
2750        let mut tracker = VariableHeightsFenwick::new(3, 5);
2751        assert_eq!(tracker.total_height(), 15);
2752        tracker.clear();
2753        assert_eq!(tracker.len(), 0);
2754        assert_eq!(tracker.total_height(), 0);
2755        assert_eq!(tracker.find_item_at_offset(0), 0);
2756    }
2757
2758    #[test]
2759    fn fenwick_rebuild_replaces_data() {
2760        let mut tracker = VariableHeightsFenwick::new(1, 10);
2761        assert_eq!(tracker.total_height(), 10);
2762        tracker.rebuild(&[5, 3, 2]);
2763        assert_eq!(tracker.len(), 3);
2764        assert_eq!(tracker.total_height(), 10);
2765        assert_eq!(tracker.get(0), 5);
2766        assert_eq!(tracker.get(1), 3);
2767        assert_eq!(tracker.get(2), 2);
2768    }
2769
2770    #[test]
2771    fn fenwick_resize_same_size_is_noop() {
2772        let mut tracker = VariableHeightsFenwick::new(2, 5);
2773        tracker.set(2, 10);
2774        tracker.resize(5);
2775        // Item 2 still has custom height
2776        assert_eq!(tracker.get(2), 10);
2777        assert_eq!(tracker.len(), 5);
2778    }
2779
2780    // ── VirtualizedListState edge cases ─────────────────────────────────
2781
2782    #[test]
2783    fn list_state_default_matches_new() {
2784        let d = VirtualizedListState::default();
2785        let n = VirtualizedListState::new();
2786        assert_eq!(d.selected, n.selected);
2787        assert_eq!(d.scroll_offset(), n.scroll_offset());
2788        assert_eq!(d.visible_count(), n.visible_count());
2789        assert_eq!(d.follow_mode(), n.follow_mode());
2790    }
2791
2792    #[test]
2793    fn list_state_select_next_on_empty() {
2794        let mut state = VirtualizedListState::new();
2795        state.select_next(0);
2796        assert_eq!(state.selected, None);
2797    }
2798
2799    #[test]
2800    fn list_state_select_previous_on_empty() {
2801        let mut state = VirtualizedListState::new();
2802        state.select_previous(0);
2803        assert_eq!(state.selected, None);
2804    }
2805
2806    #[test]
2807    fn list_state_select_previous_from_none() {
2808        let mut state = VirtualizedListState::new();
2809        state.select_previous(10);
2810        assert_eq!(state.selected, Some(0));
2811    }
2812
2813    #[test]
2814    fn list_state_select_next_from_none() {
2815        let mut state = VirtualizedListState::new();
2816        state.select_next(10);
2817        assert_eq!(state.selected, Some(0));
2818    }
2819
2820    #[test]
2821    fn list_state_scroll_zero_items() {
2822        let mut state = VirtualizedListState::new();
2823        state.scroll(10, 0);
2824        assert_eq!(state.scroll_offset(), 0);
2825    }
2826
2827    #[test]
2828    fn list_state_scroll_to_clamps() {
2829        let mut state = VirtualizedListState::new();
2830        state.scroll_to(100, 10);
2831        assert_eq!(state.scroll_offset(), 9);
2832    }
2833
2834    #[test]
2835    fn list_state_scroll_to_bottom_zero_items() {
2836        let mut state = VirtualizedListState::new();
2837        state.scroll_to_bottom(0);
2838        assert_eq!(state.scroll_offset(), 0);
2839    }
2840
2841    #[test]
2842    fn list_state_is_at_bottom_zero_items() {
2843        let state = VirtualizedListState::new();
2844        assert!(state.is_at_bottom(0));
2845    }
2846
2847    #[test]
2848    fn list_state_page_up_visible_count_zero() {
2849        let mut state = VirtualizedListState::new();
2850        state.scroll_offset = 5;
2851        state.page_up(20);
2852        // visible_count=0, no-op
2853        assert_eq!(state.scroll_offset(), 5);
2854    }
2855
2856    #[test]
2857    fn list_state_page_down_visible_count_zero() {
2858        let mut state = VirtualizedListState::new();
2859        state.page_down(20);
2860        // visible_count=0, no-op
2861        assert_eq!(state.scroll_offset(), 0);
2862    }
2863
2864    #[test]
2865    fn list_state_set_follow_false_no_scroll() {
2866        let mut state = VirtualizedListState::new();
2867        state.scroll_offset = 5;
2868        state.set_follow(false, 20);
2869        assert_eq!(state.scroll_offset(), 5); // unchanged
2870        assert!(!state.follow_mode());
2871    }
2872
2873    #[test]
2874    fn list_state_persistence_id() {
2875        let state = VirtualizedListState::new().with_persistence_id("my-list");
2876        assert_eq!(state.persistence_id(), Some("my-list"));
2877    }
2878
2879    #[test]
2880    fn list_state_persistence_id_none() {
2881        let state = VirtualizedListState::new();
2882        assert_eq!(state.persistence_id(), None);
2883    }
2884
2885    #[test]
2886    fn list_state_momentum_tick_zero_items() {
2887        let mut state = VirtualizedListState::new();
2888        state.fling(50.0);
2889        state.tick(Duration::from_millis(100), 0);
2890        // total_items=0, scroll is no-op
2891        assert_eq!(state.scroll_offset(), 0);
2892    }
2893
2894    // ── VirtualizedListPersistState edge cases ──────────────────────────
2895
2896    #[test]
2897    fn persist_state_default() {
2898        let ps = VirtualizedListPersistState::default();
2899        assert_eq!(ps.selected, None);
2900        assert_eq!(ps.scroll_offset, 0);
2901        assert!(!ps.follow_mode);
2902    }
2903
2904    #[test]
2905    fn persist_state_eq() {
2906        let a = VirtualizedListPersistState {
2907            selected: Some(5),
2908            scroll_offset: 10,
2909            follow_mode: true,
2910        };
2911        let b = a.clone();
2912        assert_eq!(a, b);
2913    }
2914
2915    // ── Stateful trait impl edge cases ──────────────────────────────────
2916
2917    #[test]
2918    fn stateful_state_key_with_persistence_id() {
2919        use crate::stateful::Stateful;
2920        let state = VirtualizedListState::new().with_persistence_id("logs");
2921        let key = state.state_key();
2922        assert_eq!(key.widget_type, "VirtualizedList");
2923        assert_eq!(key.instance_id, "logs");
2924    }
2925
2926    #[test]
2927    fn stateful_state_key_default_instance() {
2928        use crate::stateful::Stateful;
2929        let state = VirtualizedListState::new();
2930        let key = state.state_key();
2931        assert_eq!(key.instance_id, "default");
2932    }
2933
2934    #[test]
2935    fn stateful_save_restore_roundtrip() {
2936        use crate::stateful::Stateful;
2937        let mut state = VirtualizedListState::new();
2938        state.selected = Some(7);
2939        state.scroll_offset = 15;
2940        state.follow_mode = true;
2941        state.scroll_velocity = 42.0; // transient — not persisted
2942
2943        let saved = state.save_state();
2944        assert_eq!(saved.selected, Some(7));
2945        assert_eq!(saved.scroll_offset, 15);
2946        assert!(saved.follow_mode);
2947
2948        let mut restored = VirtualizedListState::new();
2949        restored.scroll_velocity = 99.0;
2950        restored.restore_state(saved);
2951        assert_eq!(restored.selected, Some(7));
2952        assert_eq!(restored.scroll_offset, 15);
2953        assert!(restored.follow_mode);
2954        // velocity reset to 0 on restore
2955        assert_eq!(restored.scroll_velocity, 0.0);
2956    }
2957
2958    // ── VirtualizedList widget edge cases ───────────────────────────────
2959
2960    #[test]
2961    fn virtualized_list_builder() {
2962        let items: Vec<String> = vec!["a".into()];
2963        let list = VirtualizedList::new(&items)
2964            .style(Style::default())
2965            .highlight_style(Style::default())
2966            .show_scrollbar(false)
2967            .fixed_height(3);
2968        assert_eq!(list.fixed_height, 3);
2969        assert!(!list.show_scrollbar);
2970    }
2971
2972    // ── VirtualizedStorage Debug/Clone ───────────────────────────────────
2973
2974    #[test]
2975    fn virtualized_storage_debug() {
2976        let storage: VirtualizedStorage<i32> = VirtualizedStorage::Owned(VecDeque::new());
2977        let dbg = format!("{:?}", storage);
2978        assert!(dbg.contains("Owned"));
2979
2980        let ext: VirtualizedStorage<i32> = VirtualizedStorage::External {
2981            len: 100,
2982            cache_capacity: 10,
2983        };
2984        let dbg = format!("{:?}", ext);
2985        assert!(dbg.contains("External"));
2986    }
2987
2988    #[test]
2989    fn virtualized_clone() {
2990        let mut virt: Virtualized<i32> = Virtualized::new(100);
2991        virt.push(1);
2992        virt.push(2);
2993        let cloned = virt.clone();
2994        assert_eq!(cloned.len(), 2);
2995        assert_eq!(cloned.get(0), Some(&1));
2996    }
2997}