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