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