#![forbid(unsafe_code)]
use std::cell::Cell as StdCell;
use std::collections::VecDeque;
use std::ops::Range;
use std::time::Duration;
use crate::scrollbar::{Scrollbar, ScrollbarOrientation, ScrollbarState};
use crate::{StatefulWidget, clear_text_area};
use ftui_core::geometry::Rect;
use ftui_render::cell::Cell;
use ftui_render::frame::Frame;
use ftui_style::Style;
#[derive(Debug, Clone)]
pub struct Virtualized<T> {
storage: VirtualizedStorage<T>,
scroll_offset: usize,
visible_count: StdCell<usize>,
overscan: usize,
item_height: ItemHeight,
follow_mode: bool,
scroll_velocity: f32,
}
#[derive(Debug, Clone)]
pub enum VirtualizedStorage<T> {
Owned(VecDeque<T>),
External {
len: usize,
cache_capacity: usize,
},
}
#[derive(Debug, Clone)]
pub enum ItemHeight {
Fixed(u16),
Variable(HeightCache),
VariableFenwick(VariableHeightsFenwick),
}
#[derive(Debug, Clone)]
pub struct HeightCache {
cache: Vec<Option<u16>>,
base_offset: usize,
default_height: u16,
capacity: usize,
}
impl<T> Virtualized<T> {
#[must_use]
pub fn new(capacity: usize) -> Self {
Self {
storage: VirtualizedStorage::Owned(VecDeque::with_capacity(capacity.min(1024))),
scroll_offset: 0,
visible_count: StdCell::new(0),
overscan: 2,
item_height: ItemHeight::Fixed(1),
follow_mode: false,
scroll_velocity: 0.0,
}
}
#[must_use]
pub fn external(len: usize, cache_capacity: usize) -> Self {
Self {
storage: VirtualizedStorage::External {
len,
cache_capacity,
},
scroll_offset: 0,
visible_count: StdCell::new(0),
overscan: 2,
item_height: ItemHeight::Fixed(1),
follow_mode: false,
scroll_velocity: 0.0,
}
}
#[must_use]
pub fn with_item_height(mut self, height: ItemHeight) -> Self {
self.item_height = height;
self
}
#[must_use]
pub fn with_fixed_height(mut self, height: u16) -> Self {
self.item_height = ItemHeight::Fixed(height);
self
}
#[must_use]
pub fn with_variable_heights_fenwick(mut self, default_height: u16, capacity: usize) -> Self {
self.item_height =
ItemHeight::VariableFenwick(VariableHeightsFenwick::new(default_height, capacity));
self
}
#[must_use]
pub fn with_overscan(mut self, overscan: usize) -> Self {
self.overscan = overscan;
self
}
#[must_use]
pub fn with_follow(mut self, follow: bool) -> Self {
self.follow_mode = follow;
self
}
#[must_use]
pub fn len(&self) -> usize {
match &self.storage {
VirtualizedStorage::Owned(items) => items.len(),
VirtualizedStorage::External { len, .. } => *len,
}
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
#[must_use]
pub fn scroll_offset(&self) -> usize {
self.scroll_offset.min(self.len().saturating_sub(1))
}
#[must_use]
pub fn visible_count(&self) -> usize {
self.visible_count.get()
}
#[must_use]
pub fn follow_mode(&self) -> bool {
self.follow_mode
}
#[must_use]
pub fn visible_range(&self, viewport_height: u16) -> Range<usize> {
if self.is_empty() || viewport_height == 0 {
self.visible_count.set(0);
return 0..0;
}
let items_visible = match &self.item_height {
ItemHeight::Fixed(h) if *h > 0 => viewport_height.div_ceil(*h) as usize,
ItemHeight::Fixed(_) => viewport_height as usize,
ItemHeight::Variable(cache) => {
let mut count = 0;
let mut total_height = 0u16;
let start = self.scroll_offset.min(self.len().saturating_sub(1));
while start + count < self.len() {
let next = cache.get(start + count);
let proposed = total_height.saturating_add(next);
total_height = proposed;
count += 1;
if total_height >= viewport_height {
break;
}
}
count
}
ItemHeight::VariableFenwick(tracker) => {
tracker.visible_count(self.scroll_offset, viewport_height)
}
};
let max_offset = self.len().saturating_sub(items_visible);
let start = self.scroll_offset.min(max_offset);
let end = start.saturating_add(items_visible).min(self.len());
self.visible_count.set(items_visible);
start..end
}
#[must_use]
pub fn render_range(&self, viewport_height: u16) -> Range<usize> {
let visible = self.visible_range(viewport_height);
let start = visible.start.saturating_sub(self.overscan);
let end = visible.end.saturating_add(self.overscan).min(self.len());
start..end
}
pub fn scroll(&mut self, delta: i32) {
if self.is_empty() {
return;
}
let visible_count = self.visible_count.get();
let max_offset = if visible_count > 0 {
self.len().saturating_sub(visible_count)
} else {
self.len().saturating_sub(1)
};
let clamped_current = self.scroll_offset.min(max_offset);
let new_offset = clamped_current
.saturating_add_signed(delta as isize)
.min(max_offset);
self.scroll_offset = new_offset;
if delta != 0 {
self.follow_mode = false;
}
}
pub fn scroll_to(&mut self, idx: usize) {
self.scroll_offset = idx.min(self.len().saturating_sub(1));
self.follow_mode = false;
}
pub fn scroll_to_bottom(&mut self) {
if self.is_empty() {
self.scroll_offset = 0;
return;
}
let visible_count = self.visible_count.get();
if visible_count == 0 {
self.scroll_offset = usize::MAX;
} else if self.len() > visible_count {
self.scroll_offset = self.len().saturating_sub(visible_count);
} else {
self.scroll_offset = 0;
}
}
pub fn scroll_to_top(&mut self) {
self.scroll_offset = 0;
self.follow_mode = false;
}
pub fn scroll_to_start(&mut self) {
self.scroll_to_top();
}
pub fn scroll_to_end(&mut self) {
self.scroll_to_bottom();
self.follow_mode = true;
}
pub fn page_up(&mut self) {
let visible_count = self.visible_count.get();
if visible_count > 0 {
let step = if visible_count > 1 {
visible_count - 1
} else {
1
};
let delta = i32::try_from(step).unwrap_or(i32::MAX);
self.scroll(-delta);
}
}
pub fn page_down(&mut self) {
let visible_count = self.visible_count.get();
if visible_count > 0 {
let step = if visible_count > 1 {
visible_count - 1
} else {
1
};
let delta = i32::try_from(step).unwrap_or(i32::MAX);
self.scroll(delta);
}
}
pub fn set_follow(&mut self, follow: bool) {
self.follow_mode = follow;
if follow {
self.scroll_to_bottom();
}
}
#[must_use]
pub fn is_at_bottom(&self) -> bool {
let visible_count = self.visible_count.get();
if self.len() <= visible_count {
true
} else {
self.scroll_offset >= self.len().saturating_sub(visible_count)
}
}
pub fn fling(&mut self, velocity: f32) {
self.scroll_velocity = velocity;
}
pub fn tick(&mut self, dt: Duration) {
if self.scroll_velocity.abs() > 0.1 {
let delta = (self.scroll_velocity * dt.as_secs_f32()) as i32;
if delta != 0 {
self.scroll(delta);
}
self.scroll_velocity *= 0.95;
} else {
self.scroll_velocity = 0.0;
}
}
pub fn set_visible_count(&self, count: usize) {
self.visible_count.set(count);
}
#[must_use]
pub fn item_height(&self) -> &ItemHeight {
&self.item_height
}
pub fn item_height_mut(&mut self) -> &mut ItemHeight {
&mut self.item_height
}
}
impl<T> Virtualized<T> {
pub fn push(&mut self, item: T) {
if let VirtualizedStorage::Owned(items) = &mut self.storage {
items.push_back(item);
if self.follow_mode {
self.scroll_to_bottom();
}
}
}
#[must_use = "use the returned item (if any)"]
pub fn get(&self, idx: usize) -> Option<&T> {
if let VirtualizedStorage::Owned(items) = &self.storage {
items.get(idx)
} else {
None
}
}
#[must_use = "use the returned item (if any)"]
pub fn get_mut(&mut self, idx: usize) -> Option<&mut T> {
if let VirtualizedStorage::Owned(items) = &mut self.storage {
items.get_mut(idx)
} else {
None
}
}
pub fn clear(&mut self) {
if let VirtualizedStorage::Owned(items) = &mut self.storage {
items.clear();
}
self.scroll_offset = 0;
}
pub fn trim_front(&mut self, max: usize) -> usize {
if let VirtualizedStorage::Owned(items) = &mut self.storage
&& items.len() > max
{
let to_remove = items.len() - max;
items.drain(..to_remove);
self.scroll_offset = self.scroll_offset.saturating_sub(to_remove);
return to_remove;
}
0
}
pub fn iter(&self) -> Box<dyn Iterator<Item = &T> + '_> {
match &self.storage {
VirtualizedStorage::Owned(items) => Box::new(items.iter()),
VirtualizedStorage::External { .. } => Box::new(std::iter::empty()),
}
}
pub fn set_external_len(&mut self, len: usize) {
if let VirtualizedStorage::External { len: l, .. } = &mut self.storage {
*l = len;
if self.follow_mode {
self.scroll_to_bottom();
}
}
}
}
impl Default for HeightCache {
fn default() -> Self {
Self::new(1, 1000)
}
}
impl HeightCache {
#[must_use]
pub fn new(default_height: u16, capacity: usize) -> Self {
Self {
cache: Vec::new(),
base_offset: 0,
default_height,
capacity,
}
}
#[must_use]
pub fn get(&self, idx: usize) -> u16 {
if idx < self.base_offset {
return self.default_height;
}
let local = idx - self.base_offset;
self.cache
.get(local)
.and_then(|h| *h)
.unwrap_or(self.default_height)
}
pub fn set(&mut self, idx: usize, height: u16) {
if self.capacity == 0 {
return;
}
if idx < self.base_offset {
return;
}
let mut local = idx - self.base_offset;
if local + 1 >= self.cache.len() + self.capacity {
self.base_offset = idx.saturating_add(1).saturating_sub(self.capacity);
self.cache.clear();
local = idx - self.base_offset;
}
if local >= self.cache.len() {
self.cache.resize(local + 1, None);
}
self.cache[local] = Some(height);
if self.cache.len() > self.capacity {
let to_remove = self.cache.len() - self.capacity;
self.cache.drain(0..to_remove);
self.base_offset += to_remove;
}
}
pub fn clear(&mut self) {
self.cache.clear();
self.base_offset = 0;
}
}
use crate::fenwick::FenwickTree;
#[derive(Debug, Clone)]
pub struct VariableHeightsFenwick {
tree: FenwickTree,
default_height: u16,
len: usize,
}
impl Default for VariableHeightsFenwick {
fn default() -> Self {
Self::new(1, 0)
}
}
impl VariableHeightsFenwick {
#[must_use]
pub fn new(default_height: u16, capacity: usize) -> Self {
let tree = if capacity > 0 {
let heights: Vec<u32> = vec![u32::from(default_height); capacity];
FenwickTree::from_values(&heights)
} else {
FenwickTree::new(0)
};
Self {
tree,
default_height,
len: capacity,
}
}
#[must_use]
pub fn from_heights(heights: &[u16], default_height: u16) -> Self {
let heights_u32: Vec<u32> = heights.iter().map(|&h| u32::from(h)).collect();
Self {
tree: FenwickTree::from_values(&heights_u32),
default_height,
len: heights.len(),
}
}
#[must_use]
pub fn len(&self) -> usize {
self.len
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len == 0
}
#[must_use]
pub fn default_height(&self) -> u16 {
self.default_height
}
#[must_use]
pub fn get(&self, idx: usize) -> u16 {
if idx >= self.len {
return self.default_height;
}
self.tree.get(idx).min(u32::from(u16::MAX)) as u16
}
pub fn set(&mut self, idx: usize, height: u16) {
if idx >= self.len {
self.resize(idx + 1);
}
self.tree.set(idx, u32::from(height));
}
#[must_use]
pub fn offset_of_item(&self, idx: usize) -> u32 {
if idx == 0 || self.len == 0 {
return 0;
}
let clamped = idx.min(self.len);
if clamped > 0 {
self.tree.prefix(clamped - 1)
} else {
0
}
}
#[must_use]
pub fn find_item_at_offset(&self, offset: u32) -> usize {
if self.len == 0 {
return 0;
}
if offset == 0 {
return 0;
}
match self.tree.find_prefix(offset) {
Some(i) => {
(i + 1).min(self.len)
}
None => {
0
}
}
}
#[must_use]
pub fn visible_count(&self, start_idx: usize, viewport_height: u16) -> usize {
if self.len == 0 || viewport_height == 0 {
return 0;
}
let start = start_idx.min(self.len);
let start_offset = self.offset_of_item(start);
let end_offset = start_offset.saturating_add(u32::from(viewport_height));
let end_idx = self.find_item_at_offset(end_offset);
if end_idx > start {
if end_idx >= self.len {
return self.len.saturating_sub(start);
}
let end_item_start = self.offset_of_item(end_idx);
if end_offset > end_item_start {
end_idx - start + 1
} else {
end_idx - start
}
} else {
if viewport_height > 0 && start < self.len {
1
} else {
0
}
}
}
#[must_use]
pub fn total_height(&self) -> u32 {
self.tree.total()
}
pub fn resize(&mut self, new_len: usize) {
if new_len == self.len {
return;
}
self.tree.resize(new_len);
if new_len > self.len {
for i in self.len..new_len {
self.tree.set(i, u32::from(self.default_height));
}
}
self.len = new_len;
}
pub fn clear(&mut self) {
self.tree = FenwickTree::new(0);
self.len = 0;
}
pub fn rebuild(&mut self, heights: &[u16]) {
let heights_u32: Vec<u32> = heights.iter().map(|&h| u32::from(h)).collect();
self.tree = FenwickTree::from_values(&heights_u32);
self.len = heights.len();
}
}
pub trait RenderItem {
fn render(&self, area: Rect, frame: &mut Frame, selected: bool, skip_rows: u16);
fn height(&self) -> u16 {
1
}
}
#[derive(Debug, Clone)]
pub struct VirtualizedListState {
pub selected: Option<usize>,
scroll_offset: usize,
visible_count: usize,
overscan: usize,
follow_mode: bool,
scroll_velocity: f32,
scrollbar_drag_anchor: Option<usize>,
persistence_id: Option<String>,
}
impl Default for VirtualizedListState {
fn default() -> Self {
Self::new()
}
}
impl VirtualizedListState {
#[must_use]
pub fn new() -> Self {
Self {
selected: None,
scroll_offset: 0,
visible_count: 0,
overscan: 2,
follow_mode: false,
scroll_velocity: 0.0,
scrollbar_drag_anchor: None,
persistence_id: None,
}
}
#[must_use]
pub fn with_overscan(mut self, overscan: usize) -> Self {
self.overscan = overscan;
self
}
#[must_use]
pub fn with_follow(mut self, follow: bool) -> Self {
self.follow_mode = follow;
self
}
#[must_use]
pub fn with_persistence_id(mut self, id: impl Into<String>) -> Self {
self.persistence_id = Some(id.into());
self
}
#[must_use = "use the persistence id (if any)"]
pub fn persistence_id(&self) -> Option<&str> {
self.persistence_id.as_deref()
}
#[must_use]
pub fn scroll_offset(&self) -> usize {
self.scroll_offset
}
#[must_use]
pub fn scroll_offset_clamped(&self, total_items: usize) -> usize {
if total_items == 0 {
return 0;
}
self.scroll_offset.min(total_items.saturating_sub(1))
}
#[must_use]
pub fn visible_count(&self) -> usize {
self.visible_count
}
pub fn scroll(&mut self, delta: i32, total_items: usize) {
if total_items == 0 {
return;
}
let max_offset = if self.visible_count > 0 {
total_items.saturating_sub(self.visible_count)
} else {
total_items.saturating_sub(1)
};
let clamped_current = self.scroll_offset.min(max_offset);
let new_offset = clamped_current
.saturating_add_signed(delta as isize)
.min(max_offset);
self.scroll_offset = new_offset;
if delta != 0 {
self.follow_mode = false;
}
}
pub fn scroll_to(&mut self, idx: usize, total_items: usize) {
self.scroll_offset = idx.min(total_items.saturating_sub(1));
self.follow_mode = false;
}
pub fn scroll_to_top(&mut self) {
self.scroll_offset = 0;
self.follow_mode = false;
}
pub fn scroll_to_bottom(&mut self, total_items: usize) {
if total_items == 0 {
self.scroll_offset = 0;
} else {
self.scroll_offset = usize::MAX;
}
}
pub fn page_up(&mut self, total_items: usize) {
if self.visible_count > 0 {
let step = if self.visible_count > 1 {
self.visible_count - 1
} else {
1
};
let delta = i32::try_from(step).unwrap_or(i32::MAX);
self.scroll(-delta, total_items);
}
}
pub fn page_down(&mut self, total_items: usize) {
if self.visible_count > 0 {
let step = if self.visible_count > 1 {
self.visible_count - 1
} else {
1
};
let delta = i32::try_from(step).unwrap_or(i32::MAX);
self.scroll(delta, total_items);
}
}
pub fn select(&mut self, index: Option<usize>) {
self.selected = index;
}
pub fn select_previous(&mut self, total_items: usize) {
if total_items == 0 {
self.selected = None;
return;
}
self.selected = Some(match self.selected {
Some(i) if i > 0 => i - 1,
Some(_) => 0,
None => 0,
});
}
pub fn select_next(&mut self, total_items: usize) {
if total_items == 0 {
self.selected = None;
return;
}
self.selected = Some(match self.selected {
Some(i) if i < total_items - 1 => i + 1,
Some(i) => i,
None => 0,
});
}
#[must_use]
pub fn is_at_bottom(&self, total_items: usize) -> bool {
if total_items <= self.visible_count {
true
} else {
self.scroll_offset >= total_items - self.visible_count
}
}
pub fn set_follow(&mut self, follow: bool, total_items: usize) {
self.follow_mode = follow;
if follow {
self.scroll_to_bottom(total_items);
}
}
#[must_use]
pub fn follow_mode(&self) -> bool {
self.follow_mode
}
pub fn fling(&mut self, velocity: f32) {
self.scroll_velocity = velocity;
}
pub fn tick(&mut self, dt: Duration, total_items: usize) {
if self.scroll_velocity.abs() > 0.1 {
let delta = (self.scroll_velocity * dt.as_secs_f32()) as i32;
if delta != 0 {
self.scroll(delta, total_items);
}
self.scroll_velocity *= 0.95;
} else {
self.scroll_velocity = 0.0;
}
}
pub fn handle_mouse(
&mut self,
event: &ftui_core::event::MouseEvent,
hit: Option<(
ftui_render::frame::HitId,
ftui_render::frame::HitRegion,
u64,
)>,
scrollbar_hit_id: ftui_render::frame::HitId,
total_items: usize,
viewport_height: u16,
fixed_item_height: u16,
) -> crate::mouse::MouseResult {
let items_per_viewport = viewport_height.div_ceil(fixed_item_height.max(1)) as usize;
let mut scrollbar_state =
ScrollbarState::new(total_items, self.scroll_offset, items_per_viewport);
scrollbar_state.drag_anchor = self.scrollbar_drag_anchor;
let result = scrollbar_state.handle_mouse(event, hit, scrollbar_hit_id);
self.scroll_offset = scrollbar_state.position;
self.scrollbar_drag_anchor = scrollbar_state.drag_anchor;
if result == crate::mouse::MouseResult::Scrolled {
self.follow_mode = false;
}
result
}
}
#[derive(Clone, Debug, Default, PartialEq)]
#[cfg_attr(
feature = "state-persistence",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct VirtualizedListPersistState {
pub selected: Option<usize>,
pub scroll_offset: usize,
pub follow_mode: bool,
}
impl crate::stateful::Stateful for VirtualizedListState {
type State = VirtualizedListPersistState;
fn state_key(&self) -> crate::stateful::StateKey {
crate::stateful::StateKey::new(
"VirtualizedList",
self.persistence_id.as_deref().unwrap_or("default"),
)
}
fn save_state(&self) -> VirtualizedListPersistState {
VirtualizedListPersistState {
selected: self.selected,
scroll_offset: self.scroll_offset,
follow_mode: self.follow_mode,
}
}
fn restore_state(&mut self, state: VirtualizedListPersistState) {
self.selected = state.selected;
self.scroll_offset = state.scroll_offset;
self.follow_mode = state.follow_mode;
self.scroll_velocity = 0.0;
self.scrollbar_drag_anchor = None;
}
}
#[derive(Debug)]
pub struct VirtualizedList<'a, T> {
items: &'a [T],
style: Style,
highlight_style: Style,
show_scrollbar: bool,
fixed_height: u16,
hit_id: Option<ftui_render::frame::HitId>,
}
impl<'a, T> VirtualizedList<'a, T> {
#[must_use]
pub fn new(items: &'a [T]) -> Self {
Self {
items,
style: Style::default(),
highlight_style: Style::default(),
show_scrollbar: true,
fixed_height: 1,
hit_id: None,
}
}
#[must_use]
pub fn style(mut self, style: Style) -> Self {
self.style = style;
self
}
#[must_use]
pub fn highlight_style(mut self, style: Style) -> Self {
self.highlight_style = style;
self
}
#[must_use]
pub fn show_scrollbar(mut self, show: bool) -> Self {
self.show_scrollbar = show;
self
}
#[must_use]
pub fn fixed_height(mut self, height: u16) -> Self {
self.fixed_height = height;
self
}
#[must_use]
pub fn hit_id(mut self, id: ftui_render::frame::HitId) -> Self {
self.hit_id = Some(id);
self
}
}
impl<T: RenderItem> StatefulWidget for VirtualizedList<'_, T> {
type State = VirtualizedListState;
fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
#[cfg(feature = "tracing")]
let _span = tracing::debug_span!(
"widget_render",
widget = "VirtualizedList",
x = area.x,
y = area.y,
w = area.width,
h = area.height,
items = self.items.len()
)
.entered();
if area.is_empty() {
return;
}
clear_text_area(frame, area, self.style);
let total_items = self.items.len();
if total_items == 0 {
return;
}
let fixed_h = self.fixed_height.max(1);
let items_per_viewport = area.height.div_ceil(fixed_h) as usize;
let fully_visible_items = (area.height / fixed_h) as usize;
let needs_scrollbar = self.show_scrollbar && total_items > fully_visible_items;
let content_width = if needs_scrollbar {
area.width.saturating_sub(1)
} else {
area.width
};
if let Some(selected) = state.selected
&& selected >= total_items
{
state.selected = if total_items > 0 {
Some(total_items - 1)
} else {
None
};
}
if let Some(selected) = state.selected {
let vis_count = fully_visible_items.max(1);
if selected >= state.scroll_offset.saturating_add(vis_count) {
state.scroll_offset = selected.saturating_sub(vis_count.saturating_sub(1));
} else if selected < state.scroll_offset {
state.scroll_offset = selected;
}
}
let max_offset = if fully_visible_items > 0 {
total_items.saturating_sub(fully_visible_items)
} else {
total_items.saturating_sub(1)
};
state.scroll_offset = state.scroll_offset.min(max_offset);
state.visible_count = fully_visible_items.max(1).min(total_items);
let render_start = state.scroll_offset.saturating_sub(state.overscan);
let render_end = state
.scroll_offset
.saturating_add(items_per_viewport)
.saturating_add(state.overscan)
.min(total_items);
for idx in render_start..render_end {
let relative_idx = if idx >= state.scroll_offset {
i32::try_from(idx - state.scroll_offset).unwrap_or(i32::MAX)
} else {
-(i32::try_from(state.scroll_offset - idx).unwrap_or(i32::MAX))
};
let height_i32 = i32::from(self.fixed_height);
let y_offset = relative_idx.saturating_mul(height_i32);
if y_offset.saturating_add(height_i32) <= 0 {
continue;
}
if y_offset >= i32::from(area.height) {
break;
}
let skip_rows = if y_offset < 0 {
y_offset.unsigned_abs() as u16
} else {
0
};
let y = i32::from(area.y)
.saturating_add(y_offset)
.clamp(i32::from(area.y), i32::from(u16::MAX)) as u16;
if y >= area.bottom() {
break;
}
let visible_height = self
.fixed_height
.saturating_sub(skip_rows)
.min(area.bottom().saturating_sub(y));
if visible_height == 0 {
continue;
}
let row_area = Rect::new(area.x, y, content_width, visible_height);
let is_selected = state.selected == Some(idx);
let row_style = if is_selected {
self.highlight_style.merge(&self.style)
} else {
self.style
};
clear_text_area(frame, row_area, row_style);
self.items[idx].render(row_area, frame, is_selected, skip_rows);
}
if needs_scrollbar {
let scrollbar_area = Rect::new(area.right().saturating_sub(1), area.y, 1, area.height);
let mut scrollbar_state =
ScrollbarState::new(total_items, state.scroll_offset, items_per_viewport);
scrollbar_state.drag_anchor = state.scrollbar_drag_anchor;
let mut scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight);
if let Some(id) = self.hit_id {
scrollbar = scrollbar.hit_id(id);
}
scrollbar.render(scrollbar_area, frame, &mut scrollbar_state);
}
}
}
impl RenderItem for String {
fn render(&self, area: Rect, frame: &mut Frame, _selected: bool, skip_rows: u16) {
if area.is_empty() {
return;
}
let max_chars = area.width as usize;
if skip_rows > 0 {
return;
}
for (i, ch) in self.chars().take(max_chars).enumerate() {
frame
.buffer
.set(area.x.saturating_add(i as u16), area.y, Cell::from_char(ch));
}
}
}
impl RenderItem for &str {
fn render(&self, area: Rect, frame: &mut Frame, _selected: bool, skip_rows: u16) {
if area.is_empty() {
return;
}
if skip_rows > 0 {
return;
}
let max_chars = area.width as usize;
for (i, ch) in self.chars().take(max_chars).enumerate() {
frame
.buffer
.set(area.x.saturating_add(i as u16), area.y, Cell::from_char(ch));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
fn raw_row_text(frame: &Frame, y: u16) -> String {
let width = frame.buffer.width();
let mut actual = String::new();
for x in 0..width {
let ch = frame
.buffer
.get(x, y)
.and_then(|cell| cell.content.as_char())
.unwrap_or(' ');
actual.push(ch);
}
actual
}
#[test]
fn test_new_virtualized() {
let virt: Virtualized<String> = Virtualized::new(100);
assert_eq!(virt.len(), 0);
assert!(virt.is_empty());
}
#[test]
fn test_push_and_len() {
let mut virt: Virtualized<i32> = Virtualized::new(100);
virt.push(1);
virt.push(2);
virt.push(3);
assert_eq!(virt.len(), 3);
assert!(!virt.is_empty());
}
#[test]
fn test_visible_range_fixed_height() {
let mut virt: Virtualized<i32> = Virtualized::new(100).with_fixed_height(2);
for i in 0..20 {
virt.push(i);
}
let range = virt.visible_range(20);
assert_eq!(range, 0..10);
}
#[test]
fn test_visible_range_variable_height_clamps() {
let mut cache = HeightCache::new(1, 16);
cache.set(0, 3);
cache.set(1, 3);
cache.set(2, 3);
let mut virt: Virtualized<i32> =
Virtualized::new(10).with_item_height(ItemHeight::Variable(cache));
for i in 0..3 {
virt.push(i);
}
let range = virt.visible_range(5);
assert_eq!(range, 0..2);
}
#[test]
fn test_visible_range_variable_height_exact_fit() {
let mut cache = HeightCache::new(1, 16);
cache.set(0, 2);
cache.set(1, 3);
let mut virt: Virtualized<i32> =
Virtualized::new(10).with_item_height(ItemHeight::Variable(cache));
for i in 0..2 {
virt.push(i);
}
let range = virt.visible_range(5);
assert_eq!(range, 0..2);
}
#[test]
fn test_visible_range_with_scroll() {
let mut virt: Virtualized<i32> = Virtualized::new(100).with_fixed_height(1);
for i in 0..50 {
virt.push(i);
}
virt.scroll(10);
let range = virt.visible_range(10);
assert_eq!(range, 10..20);
}
#[test]
fn test_visible_range_variable_height_excludes_partial() {
let mut cache = HeightCache::new(1, 16);
cache.set(0, 6);
cache.set(1, 6);
let mut virt: Virtualized<i32> =
Virtualized::new(100).with_item_height(ItemHeight::Variable(cache));
virt.push(1);
virt.push(2);
virt.push(3);
let range = virt.visible_range(10);
assert_eq!(range, 0..2);
}
#[test]
fn test_visible_range_variable_height_exact_fit_larger() {
let mut cache = HeightCache::new(1, 16);
cache.set(0, 4);
cache.set(1, 6);
let mut virt: Virtualized<i32> =
Virtualized::new(100).with_item_height(ItemHeight::Variable(cache));
virt.push(1);
virt.push(2);
virt.push(3);
let range = virt.visible_range(10);
assert_eq!(range, 0..2);
}
#[test]
fn test_visible_range_variable_height_default_for_unmeasured() {
let cache = HeightCache::new(2, 16);
let mut virt: Virtualized<i32> =
Virtualized::new(10).with_item_height(ItemHeight::Variable(cache));
for i in 0..3 {
virt.push(i);
}
let range = virt.visible_range(5);
assert_eq!(range, 0..3);
}
#[test]
fn test_render_range_with_overscan() {
let mut virt: Virtualized<i32> =
Virtualized::new(100).with_fixed_height(1).with_overscan(2);
for i in 0..50 {
virt.push(i);
}
virt.scroll(10);
let range = virt.render_range(10);
assert_eq!(range, 8..22);
}
#[test]
fn test_scroll_bounds() {
let mut virt: Virtualized<i32> = Virtualized::new(100);
for i in 0..10 {
virt.push(i);
}
virt.scroll(-100);
assert_eq!(virt.scroll_offset(), 0);
virt.scroll(100);
assert_eq!(virt.scroll_offset(), 9);
}
#[test]
fn test_scroll_to() {
let mut virt: Virtualized<i32> = Virtualized::new(100);
for i in 0..20 {
virt.push(i);
}
virt.scroll_to(15);
assert_eq!(virt.scroll_offset(), 15);
virt.scroll_to(100);
assert_eq!(virt.scroll_offset(), 19);
}
#[test]
fn test_follow_mode() {
let mut virt: Virtualized<i32> = Virtualized::new(100).with_follow(true);
virt.set_visible_count(5);
for i in 0..10 {
virt.push(i);
}
assert!(virt.is_at_bottom());
virt.scroll(-5);
assert!(!virt.follow_mode());
}
#[test]
fn test_scroll_to_start_and_end() {
let mut virt: Virtualized<i32> = Virtualized::new(100);
virt.set_visible_count(5);
for i in 0..20 {
virt.push(i);
}
virt.scroll_to(10);
virt.set_follow(true);
virt.scroll_to_start();
assert_eq!(virt.scroll_offset(), 0);
assert!(!virt.follow_mode());
virt.scroll_to_end();
assert!(virt.is_at_bottom());
assert!(virt.follow_mode());
}
#[test]
fn test_virtualized_page_navigation() {
let mut virt: Virtualized<i32> = Virtualized::new(100);
virt.set_visible_count(5);
for i in 0..30 {
virt.push(i);
}
virt.scroll_to(15);
virt.page_up();
assert_eq!(virt.scroll_offset(), 11);
virt.page_down();
assert_eq!(virt.scroll_offset(), 15);
virt.scroll_to(2);
virt.page_up();
assert_eq!(virt.scroll_offset(), 0);
}
#[test]
fn test_height_cache() {
let mut cache = HeightCache::new(1, 100);
assert_eq!(cache.get(0), 1);
assert_eq!(cache.get(50), 1);
cache.set(5, 3);
assert_eq!(cache.get(5), 3);
assert_eq!(cache.get(4), 1);
assert_eq!(cache.get(6), 1);
}
#[test]
fn test_height_cache_large_index_window() {
let mut cache = HeightCache::new(1, 8);
cache.set(10_000, 4);
assert_eq!(cache.get(10_000), 4);
assert_eq!(cache.get(0), 1);
assert!(cache.cache.len() <= cache.capacity);
}
#[test]
fn test_clear() {
let mut virt: Virtualized<i32> = Virtualized::new(100);
for i in 0..10 {
virt.push(i);
}
virt.scroll(5);
virt.clear();
assert_eq!(virt.len(), 0);
assert_eq!(virt.scroll_offset(), 0);
}
#[test]
fn test_get_item() {
let mut virt: Virtualized<String> = Virtualized::new(100);
virt.push("hello".to_string());
virt.push("world".to_string());
assert_eq!(virt.get(0), Some(&"hello".to_string()));
assert_eq!(virt.get(1), Some(&"world".to_string()));
assert_eq!(virt.get(2), None);
}
#[test]
fn test_external_storage_len() {
let mut virt: Virtualized<i32> = Virtualized::external(1000, 100);
assert_eq!(virt.len(), 1000);
virt.set_external_len(2000);
assert_eq!(virt.len(), 2000);
}
#[test]
fn test_momentum_scrolling() {
let mut virt: Virtualized<i32> = Virtualized::new(100);
for i in 0..50 {
virt.push(i);
}
virt.fling(10.0);
virt.tick(Duration::from_millis(100));
assert!(virt.scroll_offset() > 0);
}
#[test]
fn test_virtualized_list_state_new() {
let state = VirtualizedListState::new();
assert_eq!(state.selected, None);
assert_eq!(state.scroll_offset(), 0);
assert_eq!(state.visible_count(), 0);
}
#[test]
fn test_virtualized_list_state_select_next() {
let mut state = VirtualizedListState::new();
state.select_next(10);
assert_eq!(state.selected, Some(0));
state.select_next(10);
assert_eq!(state.selected, Some(1));
state.selected = Some(9);
state.select_next(10);
assert_eq!(state.selected, Some(9));
}
#[test]
fn test_virtualized_list_state_select_previous() {
let mut state = VirtualizedListState::new();
state.selected = Some(5);
state.select_previous(10);
assert_eq!(state.selected, Some(4));
state.selected = Some(0);
state.select_previous(10);
assert_eq!(state.selected, Some(0));
}
#[test]
fn test_virtualized_list_state_scroll() {
let mut state = VirtualizedListState::new();
state.scroll(5, 20);
assert_eq!(state.scroll_offset(), 5);
state.scroll(-3, 20);
assert_eq!(state.scroll_offset(), 2);
state.scroll(-100, 20);
assert_eq!(state.scroll_offset(), 0);
state.scroll(100, 20);
assert_eq!(state.scroll_offset(), 19);
}
#[test]
fn test_virtualized_list_state_follow_mode() {
let mut state = VirtualizedListState::new().with_follow(true);
assert!(state.follow_mode());
state.scroll(5, 20);
assert!(!state.follow_mode());
}
#[test]
fn test_render_item_string() {
let s = String::from("hello");
assert_eq!(s.height(), 1);
}
#[test]
fn test_page_up_down() {
let mut virt: Virtualized<i32> = Virtualized::new(100);
for i in 0..50 {
virt.push(i);
}
virt.set_visible_count(10);
assert_eq!(virt.scroll_offset(), 0);
virt.page_down();
assert_eq!(virt.scroll_offset(), 9);
virt.page_down();
assert_eq!(virt.scroll_offset(), 18);
virt.page_up();
assert_eq!(virt.scroll_offset(), 9);
virt.page_up();
assert_eq!(virt.scroll_offset(), 0);
virt.page_up();
assert_eq!(virt.scroll_offset(), 0);
}
#[test]
fn test_render_scales_with_visible_not_total() {
use ftui_render::grapheme_pool::GraphemePool;
use std::time::Instant;
let small_items: Vec<String> = (0..1_000).map(|i| format!("Line {}", i)).collect();
let small_list = VirtualizedList::new(&small_items);
let mut small_state = VirtualizedListState::new();
let area = Rect::new(0, 0, 80, 24);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(80, 24, &mut pool);
small_list.render(area, &mut frame, &mut small_state);
let start = Instant::now();
for _ in 0..100 {
frame.buffer.clear();
small_list.render(area, &mut frame, &mut small_state);
}
let small_time = start.elapsed();
let large_items: Vec<String> = (0..100_000).map(|i| format!("Line {}", i)).collect();
let large_list = VirtualizedList::new(&large_items);
let mut large_state = VirtualizedListState::new();
large_list.render(area, &mut frame, &mut large_state);
let start = Instant::now();
for _ in 0..100 {
frame.buffer.clear();
large_list.render(area, &mut frame, &mut large_state);
}
let large_time = start.elapsed();
assert!(
large_time < small_time * 3,
"Render does not scale O(visible): 1K={:?}, 100K={:?}",
small_time,
large_time
);
}
#[test]
fn test_scroll_is_constant_time() {
use std::time::Instant;
let mut small: Virtualized<i32> = Virtualized::new(1_000);
for i in 0..1_000 {
small.push(i);
}
small.set_visible_count(24);
let mut large: Virtualized<i32> = Virtualized::new(100_000);
for i in 0..100_000 {
large.push(i);
}
large.set_visible_count(24);
let iterations = 10_000;
let start = Instant::now();
for _ in 0..iterations {
small.scroll(1);
small.scroll(-1);
}
let small_time = start.elapsed();
let start = Instant::now();
for _ in 0..iterations {
large.scroll(1);
large.scroll(-1);
}
let large_time = start.elapsed();
assert!(
large_time < small_time * 3,
"Scroll is not O(1): 1K={:?}, 100K={:?}",
small_time,
large_time
);
}
#[test]
fn render_partially_offscreen_top_skips_item() {
use ftui_render::grapheme_pool::GraphemePool;
struct IndexedItem(usize);
impl RenderItem for IndexedItem {
fn render(&self, area: Rect, frame: &mut Frame, _selected: bool, _skip_rows: u16) {
let ch = char::from_digit(self.0 as u32, 10).unwrap();
for y in area.y..area.bottom() {
frame.buffer.set(area.x, y, Cell::from_char(ch));
}
}
fn height(&self) -> u16 {
2
}
}
let items = vec![
IndexedItem(0),
IndexedItem(1),
IndexedItem(2),
IndexedItem(3),
];
let list = VirtualizedList::new(&items).fixed_height(2);
let mut state = VirtualizedListState::new().with_overscan(1);
state.scroll_offset = 1;
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 5, &mut pool);
list.render(Rect::new(0, 0, 10, 5), &mut frame, &mut state);
let cell = frame.buffer.get(0, 0).unwrap();
assert_eq!(cell.content.as_char(), Some('1'));
}
#[test]
fn render_bottom_boundary_clips_partial_item() {
use ftui_render::grapheme_pool::GraphemePool;
struct IndexedItem(u16);
impl RenderItem for IndexedItem {
fn render(&self, area: Rect, frame: &mut Frame, _selected: bool, _skip_rows: u16) {
let ch = char::from_digit(self.0 as u32, 10).unwrap();
for y in area.y..area.bottom() {
frame.buffer.set(area.x, y, Cell::from_char(ch));
}
}
fn height(&self) -> u16 {
2
}
}
let items = vec![IndexedItem(0), IndexedItem(1), IndexedItem(2)];
let list = VirtualizedList::new(&items)
.fixed_height(2)
.show_scrollbar(false);
let mut state = VirtualizedListState::new();
let mut pool = GraphemePool::new();
let mut frame = Frame::new(4, 4, &mut pool);
list.render(Rect::new(0, 0, 4, 3), &mut frame, &mut state);
assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('0'));
assert_eq!(frame.buffer.get(0, 1).unwrap().content.as_char(), Some('0'));
assert_eq!(frame.buffer.get(0, 2).unwrap().content.as_char(), Some('1'));
assert_eq!(frame.buffer.get(0, 3).unwrap().content.as_char(), None);
}
#[test]
fn render_after_fling_advances_visible_rows() {
use ftui_render::grapheme_pool::GraphemePool;
struct IndexedItem(u16);
impl RenderItem for IndexedItem {
fn render(&self, area: Rect, frame: &mut Frame, _selected: bool, _skip_rows: u16) {
let ch = char::from_digit(self.0 as u32, 10).unwrap();
for y in area.y..area.bottom() {
frame.buffer.set(area.x, y, Cell::from_char(ch));
}
}
}
let items: Vec<IndexedItem> = (0..10).map(IndexedItem).collect();
let list = VirtualizedList::new(&items)
.fixed_height(1)
.show_scrollbar(false);
let mut state = VirtualizedListState::new();
let mut pool = GraphemePool::new();
let mut frame = Frame::new(4, 3, &mut pool);
let area = Rect::new(0, 0, 4, 3);
list.render(area, &mut frame, &mut state);
assert_eq!(state.scroll_offset(), 0);
assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('0'));
state.fling(40.0);
state.tick(Duration::from_millis(100), items.len());
assert_eq!(state.scroll_offset(), 4);
frame.buffer.clear();
list.render(area, &mut frame, &mut state);
assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('4'));
}
#[test]
fn render_empty_virtualized_list_clears_stale_viewport() {
use ftui_render::grapheme_pool::GraphemePool;
let items: Vec<String> = Vec::new();
let list = VirtualizedList::new(&items).show_scrollbar(false);
let mut state = VirtualizedListState::new();
let mut pool = GraphemePool::new();
let mut frame = Frame::new(6, 3, &mut pool);
let area = Rect::new(0, 0, 6, 3);
frame.buffer.fill(area, Cell::from_char('X'));
list.render(area, &mut frame, &mut state);
assert_eq!(raw_row_text(&frame, 0), " ");
assert_eq!(raw_row_text(&frame, 1), " ");
assert_eq!(raw_row_text(&frame, 2), " ");
}
#[test]
fn render_shorter_virtualized_row_clears_stale_suffix() {
use ftui_render::grapheme_pool::GraphemePool;
let long_items = vec!["Hello".to_string()];
let short_items = vec!["Hi".to_string()];
let area = Rect::new(0, 0, 6, 1);
let mut state = VirtualizedListState::new();
let mut pool = GraphemePool::new();
let mut frame = Frame::new(6, 1, &mut pool);
VirtualizedList::new(&long_items)
.show_scrollbar(false)
.render(area, &mut frame, &mut state);
VirtualizedList::new(&short_items)
.show_scrollbar(false)
.render(area, &mut frame, &mut state);
assert_eq!(raw_row_text(&frame, 0), "Hi ");
}
#[test]
fn test_memory_bounded_by_ring_capacity() {
use crate::log_ring::LogRing;
let mut ring: LogRing<String> = LogRing::new(1_000);
for i in 0..100_000 {
ring.push(format!("Line {}", i));
}
assert_eq!(ring.len(), 1_000);
assert_eq!(ring.total_count(), 100_000);
assert_eq!(ring.first_index(), 99_000);
assert!(ring.get(99_999).is_some());
assert!(ring.get(99_000).is_some());
assert!(ring.get(0).is_none());
assert!(ring.get(98_999).is_none());
}
#[test]
fn test_visible_range_constant_regardless_of_total() {
let mut small: Virtualized<i32> = Virtualized::new(100);
for i in 0..100 {
small.push(i);
}
let small_range = small.visible_range(24);
let mut large: Virtualized<i32> = Virtualized::new(100_000);
for i in 0..100_000 {
large.push(i);
}
let large_range = large.visible_range(24);
assert_eq!(small_range.end - small_range.start, 24);
assert_eq!(large_range.end - large_range.start, 24);
}
#[test]
fn test_virtualized_list_state_page_up_down() {
let mut state = VirtualizedListState::new();
state.visible_count = 10;
state.page_down(50);
assert_eq!(state.scroll_offset(), 9);
state.page_down(50);
assert_eq!(state.scroll_offset(), 18);
state.page_up(50);
assert_eq!(state.scroll_offset(), 9);
state.page_up(50);
assert_eq!(state.scroll_offset(), 0);
}
#[test]
fn test_variable_heights_fenwick_new() {
let tracker = VariableHeightsFenwick::new(2, 10);
assert_eq!(tracker.len(), 10);
assert!(!tracker.is_empty());
assert_eq!(tracker.default_height(), 2);
}
#[test]
fn test_variable_heights_fenwick_empty() {
let tracker = VariableHeightsFenwick::new(1, 0);
assert!(tracker.is_empty());
assert_eq!(tracker.total_height(), 0);
}
#[test]
fn test_variable_heights_fenwick_from_heights() {
let heights = vec![3, 2, 5, 1, 4];
let tracker = VariableHeightsFenwick::from_heights(&heights, 1);
assert_eq!(tracker.len(), 5);
assert_eq!(tracker.get(0), 3);
assert_eq!(tracker.get(1), 2);
assert_eq!(tracker.get(2), 5);
assert_eq!(tracker.get(3), 1);
assert_eq!(tracker.get(4), 4);
assert_eq!(tracker.total_height(), 15);
}
#[test]
fn test_variable_heights_fenwick_offset_of_item() {
let heights = vec![3, 2, 5, 1, 4];
let tracker = VariableHeightsFenwick::from_heights(&heights, 1);
assert_eq!(tracker.offset_of_item(0), 0);
assert_eq!(tracker.offset_of_item(1), 3);
assert_eq!(tracker.offset_of_item(2), 5);
assert_eq!(tracker.offset_of_item(3), 10);
assert_eq!(tracker.offset_of_item(4), 11);
assert_eq!(tracker.offset_of_item(5), 15); }
#[test]
fn test_variable_heights_fenwick_find_item_at_offset() {
let heights = vec![3, 2, 5, 1, 4];
let tracker = VariableHeightsFenwick::from_heights(&heights, 1);
assert_eq!(tracker.find_item_at_offset(0), 0);
assert_eq!(tracker.find_item_at_offset(1), 0);
assert_eq!(tracker.find_item_at_offset(3), 1);
assert_eq!(tracker.find_item_at_offset(5), 2);
assert_eq!(tracker.find_item_at_offset(10), 3);
assert_eq!(tracker.find_item_at_offset(11), 4);
assert_eq!(tracker.find_item_at_offset(15), 5);
}
#[test]
fn test_variable_heights_fenwick_visible_count() {
let heights = vec![3, 2, 5, 1, 4];
let tracker = VariableHeightsFenwick::from_heights(&heights, 1);
assert_eq!(tracker.visible_count(0, 5), 2);
assert_eq!(tracker.visible_count(0, 4), 2);
assert_eq!(tracker.visible_count(0, 10), 3);
assert_eq!(tracker.visible_count(2, 6), 2);
}
#[test]
fn test_variable_heights_fenwick_visible_count_viewport_beyond_total_height() {
let heights = vec![1, 1, 1];
let tracker = VariableHeightsFenwick::from_heights(&heights, 1);
assert_eq!(tracker.visible_count(0, 10), 3);
assert_eq!(tracker.visible_count(1, 10), 2);
assert_eq!(tracker.visible_count(2, 10), 1);
}
#[test]
fn test_variable_heights_fenwick_set() {
let mut tracker = VariableHeightsFenwick::new(1, 5);
assert_eq!(tracker.get(0), 1);
assert_eq!(tracker.total_height(), 5);
tracker.set(2, 10);
assert_eq!(tracker.get(2), 10);
assert_eq!(tracker.total_height(), 14); }
#[test]
fn test_variable_heights_fenwick_resize() {
let mut tracker = VariableHeightsFenwick::new(2, 3);
assert_eq!(tracker.len(), 3);
assert_eq!(tracker.total_height(), 6);
tracker.resize(5);
assert_eq!(tracker.len(), 5);
assert_eq!(tracker.total_height(), 10);
assert_eq!(tracker.get(4), 2);
tracker.resize(2);
assert_eq!(tracker.len(), 2);
assert_eq!(tracker.total_height(), 4);
}
#[test]
fn test_variable_heights_fenwick_point_update_and_range_query() {
fn range_sum(tracker: &VariableHeightsFenwick, left: usize, right: usize) -> u32 {
tracker
.offset_of_item(right.saturating_add(1))
.saturating_sub(tracker.offset_of_item(left))
}
let mut tracker = VariableHeightsFenwick::from_heights(&[2, 4, 1, 3, 5, 2], 1);
let mut naive = [2_u32, 4, 1, 3, 5, 2];
tracker.set(2, 7);
naive[2] = 7;
tracker.set(5, 1);
naive[5] = 1;
tracker.set(0, 6);
naive[0] = 6;
let mut running = 0u32;
for (i, value) in naive.iter().enumerate() {
running = running.saturating_add(*value);
assert_eq!(tracker.offset_of_item(i + 1), running);
}
let naive_sum = |left: usize, right: usize| -> u32 { naive[left..=right].iter().sum() };
assert_eq!(range_sum(&tracker, 0, 0), naive_sum(0, 0));
assert_eq!(range_sum(&tracker, 1, 3), naive_sum(1, 3));
assert_eq!(range_sum(&tracker, 2, 5), naive_sum(2, 5));
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(96))]
#[test]
fn property_variable_heights_fenwick_prefix_sums_match_naive(
heights in proptest::collection::vec(1u16..=32u16, 1..160)
) {
let tracker = VariableHeightsFenwick::from_heights(&heights, 1);
let mut naive_prefix = 0u32;
prop_assert_eq!(tracker.offset_of_item(0), 0);
for (i, height) in heights.iter().enumerate() {
naive_prefix = naive_prefix.saturating_add(u32::from(*height));
prop_assert_eq!(
tracker.offset_of_item(i + 1),
naive_prefix,
"prefix mismatch at index {} for heights {:?}",
i,
heights
);
}
}
#[test]
fn property_variable_heights_fenwick_visible_count_matches_naive(
heights in proptest::collection::vec(1u16..=24u16, 1..128),
start_idx in 0usize..192usize,
viewport_height in 1u16..=120u16
) {
fn naive_visible_count(heights: &[u16], start_idx: usize, viewport_height: u16) -> usize {
if heights.is_empty() || viewport_height == 0 {
return 0;
}
let start = start_idx.min(heights.len());
if start >= heights.len() {
return 0;
}
let start_offset: u32 = heights[..start].iter().map(|&h| u32::from(h)).sum();
let end_offset = start_offset.saturating_add(u32::from(viewport_height));
let mut count = 0usize;
let mut cursor = start_offset;
for &height in &heights[start..] {
if cursor >= end_offset {
break;
}
count = count.saturating_add(1);
cursor = cursor.saturating_add(u32::from(height));
}
if count == 0 { 1 } else { count }
}
let tracker = VariableHeightsFenwick::from_heights(&heights, 1);
let expected = naive_visible_count(&heights, start_idx, viewport_height);
let actual = tracker.visible_count(start_idx, viewport_height);
prop_assert_eq!(
actual,
expected,
"visible_count mismatch for start={} viewport={} heights={:?}",
start_idx,
viewport_height,
heights
);
}
}
#[test]
fn test_virtualized_with_variable_heights_fenwick() {
let mut virt: Virtualized<i32> = Virtualized::new(100).with_variable_heights_fenwick(2, 10);
for i in 0..10 {
virt.push(i);
}
let range = virt.visible_range(6);
assert_eq!(range.end - range.start, 3);
}
#[test]
fn test_variable_heights_fenwick_performance() {
use std::time::Instant;
let n = 100_000;
let heights: Vec<u16> = (0..n).map(|i| (i % 10 + 1) as u16).collect();
let tracker = VariableHeightsFenwick::from_heights(&heights, 1);
let _ = tracker.find_item_at_offset(500_000);
let _ = tracker.offset_of_item(50_000);
let start = Instant::now();
let mut _sink = 0usize;
for i in 0..10_000 {
_sink = _sink.wrapping_add(tracker.find_item_at_offset((i * 50) as u32));
}
let find_time = start.elapsed();
let start = Instant::now();
let mut _sink2 = 0u32;
for i in 0..10_000 {
_sink2 = _sink2.wrapping_add(tracker.offset_of_item((i * 10) % n));
}
let offset_time = start.elapsed();
eprintln!("=== VariableHeightsFenwick Performance (n={n}) ===");
eprintln!("10k find_item_at_offset: {:?}", find_time);
eprintln!("10k offset_of_item: {:?}", offset_time);
assert!(
find_time < std::time::Duration::from_millis(50),
"find_item_at_offset too slow: {:?}",
find_time
);
assert!(
offset_time < std::time::Duration::from_millis(50),
"offset_of_item too slow: {:?}",
offset_time
);
}
#[test]
fn test_variable_heights_fenwick_scales_logarithmically() {
use std::time::Instant;
let small_n = 1_000;
let small_heights: Vec<u16> = (0..small_n).map(|i| (i % 5 + 1) as u16).collect();
let small_tracker = VariableHeightsFenwick::from_heights(&small_heights, 1);
let large_n = 100_000;
let large_heights: Vec<u16> = (0..large_n).map(|i| (i % 5 + 1) as u16).collect();
let large_tracker = VariableHeightsFenwick::from_heights(&large_heights, 1);
let iterations = 5_000;
let start = Instant::now();
for i in 0..iterations {
let _ = small_tracker.find_item_at_offset((i * 2) as u32);
}
let small_time = start.elapsed();
let start = Instant::now();
for i in 0..iterations {
let _ = large_tracker.find_item_at_offset((i * 200) as u32);
}
let large_time = start.elapsed();
assert!(
large_time < small_time * 10,
"Not O(log n): small={:?}, large={:?}",
small_time,
large_time
);
}
#[test]
fn new_zero_capacity() {
let virt: Virtualized<i32> = Virtualized::new(0);
assert_eq!(virt.len(), 0);
assert!(virt.is_empty());
assert_eq!(virt.scroll_offset(), 0);
assert_eq!(virt.visible_count(), 0);
assert!(!virt.follow_mode());
}
#[test]
fn external_zero_len_zero_cache() {
let virt: Virtualized<i32> = Virtualized::external(0, 0);
assert_eq!(virt.len(), 0);
assert!(virt.is_empty());
}
#[test]
fn external_storage_returns_none_for_get() {
let virt: Virtualized<i32> = Virtualized::external(100, 10);
assert_eq!(virt.get(0), None);
assert_eq!(virt.get(50), None);
}
#[test]
fn external_storage_returns_none_for_get_mut() {
let mut virt: Virtualized<i32> = Virtualized::external(100, 10);
assert!(virt.get_mut(0).is_none());
}
#[test]
fn push_on_external_is_noop() {
let mut virt: Virtualized<i32> = Virtualized::external(5, 10);
virt.push(42);
assert_eq!(virt.len(), 5);
}
#[test]
fn iter_on_external_is_empty() {
let virt: Virtualized<i32> = Virtualized::external(100, 10);
assert_eq!(virt.iter().count(), 0);
}
#[test]
fn set_external_len_on_owned_is_noop() {
let mut virt: Virtualized<i32> = Virtualized::new(100);
virt.push(1);
virt.set_external_len(999);
assert_eq!(virt.len(), 1); }
#[test]
fn visible_range_zero_viewport() {
let mut virt: Virtualized<i32> = Virtualized::new(100);
virt.push(1);
let range = virt.visible_range(0);
assert_eq!(range, 0..0);
assert_eq!(virt.visible_count(), 0);
}
#[test]
fn visible_range_empty_container() {
let virt: Virtualized<i32> = Virtualized::new(100);
let range = virt.visible_range(24);
assert_eq!(range, 0..0);
}
#[test]
fn visible_range_fixed_height_zero() {
let mut virt: Virtualized<i32> = Virtualized::new(100).with_fixed_height(0);
for i in 0..10 {
virt.push(i);
}
let range = virt.visible_range(5);
assert_eq!(range, 0..5);
}
#[test]
fn visible_range_fewer_items_than_viewport() {
let mut virt: Virtualized<i32> = Virtualized::new(100);
for i in 0..3 {
virt.push(i);
}
let range = virt.visible_range(24);
assert_eq!(range, 0..3);
}
#[test]
fn visible_range_single_item() {
let mut virt: Virtualized<i32> = Virtualized::new(100);
virt.push(42);
let range = virt.visible_range(1);
assert_eq!(range, 0..1);
}
#[test]
fn render_range_at_start_clamps_overscan() {
let mut virt: Virtualized<i32> =
Virtualized::new(100).with_fixed_height(1).with_overscan(5);
for i in 0..20 {
virt.push(i);
}
let range = virt.render_range(10);
assert_eq!(range.start, 0);
}
#[test]
fn render_range_at_end_clamps_overscan() {
let mut virt: Virtualized<i32> =
Virtualized::new(100).with_fixed_height(1).with_overscan(5);
for i in 0..20 {
virt.push(i);
}
virt.set_visible_count(10);
virt.scroll_to(10); let range = virt.render_range(10);
assert_eq!(range.end, 20);
}
#[test]
fn render_range_zero_overscan() {
let mut virt: Virtualized<i32> =
Virtualized::new(100).with_fixed_height(1).with_overscan(0);
for i in 0..20 {
virt.push(i);
}
virt.set_visible_count(10);
virt.scroll_to(5);
let range = virt.render_range(10);
let visible = virt.visible_range(10);
assert_eq!(range, visible);
}
#[test]
fn scroll_on_empty_is_noop() {
let mut virt: Virtualized<i32> = Virtualized::new(100);
virt.scroll(10);
assert_eq!(virt.scroll_offset(), 0);
}
#[test]
fn scroll_delta_zero_does_not_disable_follow() {
let mut virt: Virtualized<i32> = Virtualized::new(100).with_follow(true);
virt.push(1);
virt.scroll(0);
assert!(virt.follow_mode());
}
#[test]
fn scroll_negative_beyond_start() {
let mut virt: Virtualized<i32> = Virtualized::new(100);
for i in 0..10 {
virt.push(i);
}
virt.scroll(-1);
assert_eq!(virt.scroll_offset(), 0);
}
#[test]
fn scroll_to_on_empty() {
let mut virt: Virtualized<i32> = Virtualized::new(100);
virt.scroll_to(100);
assert_eq!(virt.scroll_offset(), 0);
}
#[test]
fn scroll_to_top_already_at_top() {
let mut virt: Virtualized<i32> = Virtualized::new(100);
virt.push(1);
virt.scroll_to_top();
assert_eq!(virt.scroll_offset(), 0);
}
#[test]
fn scroll_to_bottom_fewer_items_than_visible() {
let mut virt: Virtualized<i32> = Virtualized::new(100);
virt.set_visible_count(10);
for i in 0..3 {
virt.push(i);
}
virt.scroll_to_bottom();
assert_eq!(virt.scroll_offset(), 0);
}
#[test]
fn scroll_to_bottom_visible_count_zero() {
let mut virt: Virtualized<i32> = Virtualized::new(100);
for i in 0..20 {
virt.push(i);
}
virt.scroll_to_bottom();
assert_eq!(virt.scroll_offset, usize::MAX);
assert_eq!(virt.scroll_offset(), 19);
}
#[test]
fn page_up_visible_count_zero_is_noop() {
let mut virt: Virtualized<i32> = Virtualized::new(100);
for i in 0..20 {
virt.push(i);
}
virt.scroll_to(10);
virt.page_up();
assert_eq!(virt.scroll_offset(), 10);
}
#[test]
fn page_down_visible_count_zero_is_noop() {
let mut virt: Virtualized<i32> = Virtualized::new(100);
for i in 0..20 {
virt.push(i);
}
virt.page_down();
assert_eq!(virt.scroll_offset(), 0);
}
#[test]
fn is_at_bottom_fewer_items_than_visible() {
let mut virt: Virtualized<i32> = Virtualized::new(100);
virt.set_visible_count(10);
for i in 0..3 {
virt.push(i);
}
assert!(virt.is_at_bottom());
}
#[test]
fn is_at_bottom_empty() {
let virt: Virtualized<i32> = Virtualized::new(100);
assert!(virt.is_at_bottom());
}
#[test]
fn trim_front_under_max_returns_zero() {
let mut virt: Virtualized<i32> = Virtualized::new(100);
for i in 0..5 {
virt.push(i);
}
let removed = virt.trim_front(10);
assert_eq!(removed, 0);
assert_eq!(virt.len(), 5);
}
#[test]
fn trim_front_adjusts_scroll_offset() {
let mut virt: Virtualized<i32> = Virtualized::new(100);
for i in 0..20 {
virt.push(i);
}
virt.scroll_to(10);
let removed = virt.trim_front(15);
assert_eq!(removed, 5);
assert_eq!(virt.len(), 15);
assert_eq!(virt.scroll_offset(), 5);
}
#[test]
fn trim_front_scroll_offset_saturates_to_zero() {
let mut virt: Virtualized<i32> = Virtualized::new(100);
for i in 0..20 {
virt.push(i);
}
virt.scroll_to(2);
let removed = virt.trim_front(10);
assert_eq!(removed, 10);
assert_eq!(virt.scroll_offset(), 0);
}
#[test]
fn trim_front_on_external_returns_zero() {
let mut virt: Virtualized<i32> = Virtualized::external(100, 10);
let removed = virt.trim_front(5);
assert_eq!(removed, 0);
}
#[test]
fn scroll_to_bottom_sets_sentinel_for_lazy_clamping() {
let mut virt: Virtualized<i32> = Virtualized::new(100);
for i in 0..20 {
virt.push(i);
}
virt.scroll_to_bottom();
assert_eq!(virt.scroll_offset, usize::MAX);
let range = virt.visible_range(10);
assert_eq!(range, 10..20);
assert_eq!(virt.visible_count(), 10);
}
#[test]
fn clear_on_external_resets_scroll() {
let mut virt: Virtualized<i32> = Virtualized::external(100, 10);
virt.scroll_to(50);
virt.clear();
assert_eq!(virt.scroll_offset(), 0);
assert_eq!(virt.len(), 100);
}
#[test]
fn tick_zero_velocity_is_noop() {
let mut virt: Virtualized<i32> = Virtualized::new(100);
for i in 0..20 {
virt.push(i);
}
virt.tick(Duration::from_millis(100));
assert_eq!(virt.scroll_offset(), 0);
}
#[test]
fn tick_below_threshold_stops_momentum() {
let mut virt: Virtualized<i32> = Virtualized::new(100);
for i in 0..20 {
virt.push(i);
}
virt.fling(0.05); virt.tick(Duration::from_millis(100));
assert_eq!(virt.scroll_offset(), 0);
}
#[test]
fn tick_zero_duration_no_scroll() {
let mut virt: Virtualized<i32> = Virtualized::new(100);
for i in 0..50 {
virt.push(i);
}
virt.fling(100.0);
virt.tick(Duration::ZERO);
assert_eq!(virt.scroll_offset(), 0);
}
#[test]
fn fling_negative_scrolls_up() {
let mut virt: Virtualized<i32> = Virtualized::new(100);
for i in 0..50 {
virt.push(i);
}
virt.scroll(20);
let before = virt.scroll_offset();
virt.fling(-50.0);
virt.tick(Duration::from_millis(100));
assert!(virt.scroll_offset() < before);
}
#[test]
fn follow_mode_auto_scrolls_on_push() {
let mut virt: Virtualized<i32> = Virtualized::new(100).with_follow(true);
virt.set_visible_count(5);
for i in 0..20 {
virt.push(i);
}
assert!(virt.is_at_bottom());
assert_eq!(virt.scroll_offset(), 15); }
#[test]
fn set_follow_false_does_not_scroll() {
let mut virt: Virtualized<i32> = Virtualized::new(100);
virt.set_visible_count(5);
for i in 0..20 {
virt.push(i);
}
virt.scroll_to(5);
virt.set_follow(false);
assert_eq!(virt.scroll_offset(), 5); }
#[test]
fn scroll_to_start_disables_follow() {
let mut virt: Virtualized<i32> = Virtualized::new(100).with_follow(true);
virt.set_visible_count(5);
for i in 0..20 {
virt.push(i);
}
virt.scroll_to_start();
assert!(!virt.follow_mode());
assert_eq!(virt.scroll_offset(), 0);
}
#[test]
fn scroll_to_end_enables_follow() {
let mut virt: Virtualized<i32> = Virtualized::new(100);
virt.set_visible_count(5);
for i in 0..20 {
virt.push(i);
}
assert!(!virt.follow_mode());
virt.scroll_to_end();
assert!(virt.follow_mode());
assert!(virt.is_at_bottom());
}
#[test]
fn external_follow_mode_scrolls_on_set_external_len() {
let mut virt: Virtualized<i32> = Virtualized::external(10, 100).with_follow(true);
virt.set_visible_count(5);
virt.set_external_len(20);
assert_eq!(virt.len(), 20);
assert!(virt.is_at_bottom());
}
#[test]
fn builder_chain_all_options() {
let virt: Virtualized<i32> = Virtualized::new(100)
.with_fixed_height(3)
.with_overscan(5)
.with_follow(true);
assert!(virt.follow_mode());
let range = virt.visible_range(9);
assert_eq!(range, 0..0);
}
#[test]
fn height_cache_default() {
let cache = HeightCache::default();
assert_eq!(cache.get(0), 1); assert_eq!(cache.capacity, 1000);
}
#[test]
fn height_cache_get_before_base_offset() {
let mut cache = HeightCache::new(5, 100);
cache.set(200, 10); assert_eq!(cache.get(0), 5);
}
#[test]
fn height_cache_set_before_base_offset_ignored() {
let mut cache = HeightCache::new(5, 100);
cache.set(200, 10);
let base = cache.base_offset;
cache.set(0, 99); assert_eq!(cache.get(0), 5); assert_eq!(cache.base_offset, base); }
#[test]
fn height_cache_capacity_zero_ignores_all_sets() {
let mut cache = HeightCache::new(3, 0);
cache.set(0, 10);
cache.set(5, 20);
assert_eq!(cache.get(0), 3);
assert_eq!(cache.get(5), 3);
}
#[test]
fn height_cache_clear_resets_base() {
let mut cache = HeightCache::new(1, 100);
cache.set(50, 10);
cache.clear();
assert_eq!(cache.base_offset, 0);
assert_eq!(cache.get(50), 1); }
#[test]
fn height_cache_eviction_trims_oldest() {
let mut cache = HeightCache::new(1, 4);
for i in 0..6 {
cache.set(i, (i + 10) as u16);
}
assert!(cache.cache.len() <= cache.capacity);
assert_eq!(cache.get(5), 15);
assert_eq!(cache.get(4), 14);
assert_eq!(cache.get(3), 13);
assert_eq!(cache.get(2), 12);
assert_eq!(cache.get(1), 1);
assert_eq!(cache.get(0), 1);
}
#[test]
fn fenwick_default_is_empty() {
let tracker = VariableHeightsFenwick::default();
assert!(tracker.is_empty());
assert_eq!(tracker.len(), 0);
assert_eq!(tracker.total_height(), 0);
assert_eq!(tracker.default_height(), 1);
}
#[test]
fn fenwick_get_beyond_len_returns_default() {
let tracker = VariableHeightsFenwick::new(3, 5);
assert_eq!(tracker.get(5), 3); assert_eq!(tracker.get(100), 3);
}
#[test]
fn fenwick_set_beyond_len_resizes() {
let mut tracker = VariableHeightsFenwick::new(2, 3);
assert_eq!(tracker.len(), 3);
tracker.set(10, 7);
assert!(tracker.len() > 10);
assert_eq!(tracker.get(10), 7);
}
#[test]
fn fenwick_offset_of_item_zero_always_zero() {
let tracker = VariableHeightsFenwick::new(5, 10);
assert_eq!(tracker.offset_of_item(0), 0);
let empty = VariableHeightsFenwick::new(5, 0);
assert_eq!(empty.offset_of_item(0), 0);
}
#[test]
fn fenwick_find_item_at_offset_empty() {
let tracker = VariableHeightsFenwick::new(1, 0);
assert_eq!(tracker.find_item_at_offset(0), 0);
assert_eq!(tracker.find_item_at_offset(100), 0);
}
#[test]
fn fenwick_visible_count_zero_viewport() {
let tracker = VariableHeightsFenwick::new(2, 10);
assert_eq!(tracker.visible_count(0, 0), 0);
}
#[test]
fn fenwick_visible_count_start_beyond_len() {
let tracker = VariableHeightsFenwick::new(2, 5);
let count = tracker.visible_count(100, 10);
assert_eq!(count, 0);
}
#[test]
fn fenwick_clear_then_operations() {
let mut tracker = VariableHeightsFenwick::new(3, 5);
assert_eq!(tracker.total_height(), 15);
tracker.clear();
assert_eq!(tracker.len(), 0);
assert_eq!(tracker.total_height(), 0);
assert_eq!(tracker.find_item_at_offset(0), 0);
}
#[test]
fn fenwick_rebuild_replaces_data() {
let mut tracker = VariableHeightsFenwick::new(1, 10);
assert_eq!(tracker.total_height(), 10);
tracker.rebuild(&[5, 3, 2]);
assert_eq!(tracker.len(), 3);
assert_eq!(tracker.total_height(), 10);
assert_eq!(tracker.get(0), 5);
assert_eq!(tracker.get(1), 3);
assert_eq!(tracker.get(2), 2);
}
#[test]
fn fenwick_resize_same_size_is_noop() {
let mut tracker = VariableHeightsFenwick::new(2, 5);
tracker.set(2, 10);
tracker.resize(5);
assert_eq!(tracker.get(2), 10);
assert_eq!(tracker.len(), 5);
}
#[test]
fn list_state_default_matches_new() {
let d = VirtualizedListState::default();
let n = VirtualizedListState::new();
assert_eq!(d.selected, n.selected);
assert_eq!(d.scroll_offset(), n.scroll_offset());
assert_eq!(d.visible_count(), n.visible_count());
assert_eq!(d.follow_mode(), n.follow_mode());
}
#[test]
fn list_state_select_next_on_empty() {
let mut state = VirtualizedListState::new();
state.select_next(0);
assert_eq!(state.selected, None);
}
#[test]
fn list_state_select_previous_on_empty() {
let mut state = VirtualizedListState::new();
state.select_previous(0);
assert_eq!(state.selected, None);
}
#[test]
fn list_state_select_previous_from_none() {
let mut state = VirtualizedListState::new();
state.select_previous(10);
assert_eq!(state.selected, Some(0));
}
#[test]
fn list_state_select_next_from_none() {
let mut state = VirtualizedListState::new();
state.select_next(10);
assert_eq!(state.selected, Some(0));
}
#[test]
fn list_state_scroll_zero_items() {
let mut state = VirtualizedListState::new();
state.scroll(10, 0);
assert_eq!(state.scroll_offset(), 0);
}
#[test]
fn list_state_scroll_to_clamps() {
let mut state = VirtualizedListState::new();
state.scroll_to(100, 10);
assert_eq!(state.scroll_offset(), 9);
}
#[test]
fn list_state_scroll_to_bottom_zero_items() {
let mut state = VirtualizedListState::new();
state.scroll_to_bottom(0);
assert_eq!(state.scroll_offset(), 0);
}
#[test]
fn list_state_is_at_bottom_zero_items() {
let state = VirtualizedListState::new();
assert!(state.is_at_bottom(0));
}
#[test]
fn list_state_page_up_visible_count_zero() {
let mut state = VirtualizedListState::new();
state.scroll_offset = 5;
state.page_up(20);
assert_eq!(state.scroll_offset(), 5);
}
#[test]
fn list_state_page_down_visible_count_zero() {
let mut state = VirtualizedListState::new();
state.page_down(20);
assert_eq!(state.scroll_offset(), 0);
}
#[test]
fn list_state_set_follow_false_no_scroll() {
let mut state = VirtualizedListState::new();
state.scroll_offset = 5;
state.set_follow(false, 20);
assert_eq!(state.scroll_offset(), 5); assert!(!state.follow_mode());
}
#[test]
fn list_state_persistence_id() {
let state = VirtualizedListState::new().with_persistence_id("my-list");
assert_eq!(state.persistence_id(), Some("my-list"));
}
#[test]
fn list_state_persistence_id_none() {
let state = VirtualizedListState::new();
assert_eq!(state.persistence_id(), None);
}
#[test]
fn list_state_momentum_tick_zero_items() {
let mut state = VirtualizedListState::new();
state.fling(50.0);
state.tick(Duration::from_millis(100), 0);
assert_eq!(state.scroll_offset(), 0);
}
#[test]
fn persist_state_default() {
let ps = VirtualizedListPersistState::default();
assert_eq!(ps.selected, None);
assert_eq!(ps.scroll_offset, 0);
assert!(!ps.follow_mode);
}
#[test]
fn persist_state_eq() {
let a = VirtualizedListPersistState {
selected: Some(5),
scroll_offset: 10,
follow_mode: true,
};
let b = a.clone();
assert_eq!(a, b);
}
#[test]
fn stateful_state_key_with_persistence_id() {
use crate::stateful::Stateful;
let state = VirtualizedListState::new().with_persistence_id("logs");
let key = state.state_key();
assert_eq!(key.widget_type, "VirtualizedList");
assert_eq!(key.instance_id, "logs");
}
#[test]
fn stateful_state_key_default_instance() {
use crate::stateful::Stateful;
let state = VirtualizedListState::new();
let key = state.state_key();
assert_eq!(key.instance_id, "default");
}
#[test]
fn stateful_save_restore_roundtrip() {
use crate::stateful::Stateful;
let mut state = VirtualizedListState::new();
state.selected = Some(7);
state.scroll_offset = 15;
state.follow_mode = true;
state.scroll_velocity = 42.0;
let saved = state.save_state();
assert_eq!(saved.selected, Some(7));
assert_eq!(saved.scroll_offset, 15);
assert!(saved.follow_mode);
let mut restored = VirtualizedListState::new();
restored.scroll_velocity = 99.0;
restored.restore_state(saved);
assert_eq!(restored.selected, Some(7));
assert_eq!(restored.scroll_offset, 15);
assert!(restored.follow_mode);
assert_eq!(restored.scroll_velocity, 0.0);
}
#[test]
fn virtualized_list_builder() {
let items: Vec<String> = vec!["a".into()];
let list = VirtualizedList::new(&items)
.style(Style::default())
.highlight_style(Style::default())
.show_scrollbar(false)
.fixed_height(3);
assert_eq!(list.fixed_height, 3);
assert!(!list.show_scrollbar);
}
#[test]
fn virtualized_storage_debug() {
let storage: VirtualizedStorage<i32> = VirtualizedStorage::Owned(VecDeque::new());
let dbg = format!("{:?}", storage);
assert!(dbg.contains("Owned"));
let ext: VirtualizedStorage<i32> = VirtualizedStorage::External {
len: 100,
cache_capacity: 10,
};
let dbg = format!("{:?}", ext);
assert!(dbg.contains("External"));
}
#[test]
fn test_virtualized_list_handle_mouse_drag_smooth() {
use crate::scrollbar::SCROLLBAR_PART_THUMB;
use ftui_core::event::{MouseButton, MouseEvent, MouseEventKind};
use ftui_render::frame::{HitId, HitRegion};
let mut state = VirtualizedListState::new();
let scrollbar_hit_id = HitId::new(1);
let total_items = 100;
let viewport_height = 10;
let fixed_height = 1;
let track_len = 10u64;
let track_pos = 0u64;
let hit_data = (SCROLLBAR_PART_THUMB << 56)
| ((track_len & 0x0FFF_FFFF) << 28)
| (track_pos & 0x0FFF_FFFF);
let down_event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 0, 0);
let hit = Some((scrollbar_hit_id, HitRegion::Scrollbar, hit_data));
state.handle_mouse(
&down_event,
hit,
scrollbar_hit_id,
total_items,
viewport_height,
fixed_height,
);
assert!(
state.scrollbar_drag_anchor.is_some(),
"Drag anchor should be set on down"
);
assert_eq!(
state.scrollbar_drag_anchor.unwrap(),
0,
"Anchor should be 0 (clicked top of thumb)"
);
let drag_pos = 1u64;
let drag_data = (SCROLLBAR_PART_THUMB << 56)
| ((track_len & 0x0FFF_FFFF) << 28)
| (drag_pos & 0x0FFF_FFFF);
let drag_event = MouseEvent::new(MouseEventKind::Drag(MouseButton::Left), 0, 1);
let drag_hit = Some((scrollbar_hit_id, HitRegion::Scrollbar, drag_data));
state.handle_mouse(
&drag_event,
drag_hit,
scrollbar_hit_id,
total_items,
viewport_height,
fixed_height,
);
assert_eq!(
state.scroll_offset, 10,
"Scroll offset should update smoothly"
);
}
}