use crate::core::Rect;
use crate::ontology::*;
use crate::runtime::Frame;
use crate::widget::StatefulWidget;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct VirtualListState {
pub scroll_offset: f32,
pub total_items: usize,
}
impl Default for VirtualListState {
fn default() -> Self {
Self {
scroll_offset: 0.0,
total_items: 0,
}
}
}
impl VirtualListState {
#[must_use]
pub fn new(total_items: usize) -> Self {
Self {
scroll_offset: 0.0,
total_items,
}
}
pub fn scroll_to(&mut self, index: usize, item_height: f32) {
self.scroll_offset = index as f32 * item_height;
}
}
pub struct VirtualList<F> {
item_height: f32,
render_item: F,
overscan: usize,
agent_id: String,
}
impl<F> VirtualList<F>
where
F: Fn(usize, Rect, &mut Frame<'_>),
{
pub fn new(item_height: f32, render_item: F) -> Self {
Self {
item_height,
render_item,
overscan: 2,
agent_id: String::new(),
}
}
pub fn overscan(mut self, overscan: usize) -> Self {
self.overscan = overscan;
self
}
pub fn agent_id(mut self, id: impl Into<String>) -> Self {
self.agent_id = id.into();
self
}
pub fn visible_range(
scroll_offset: f32,
viewport_height: f32,
item_height: f32,
total_items: usize,
overscan: usize,
) -> std::ops::Range<usize> {
if item_height <= 0.0 || total_items == 0 {
return 0..0;
}
let first = (scroll_offset / item_height).floor() as usize;
let visible_count = (viewport_height / item_height).ceil() as usize;
let start = first.saturating_sub(overscan);
let end = (first + visible_count + overscan).min(total_items);
start..end
}
}
impl<F> Discoverable for VirtualList<F>
where
F: Fn(usize, Rect, &mut Frame<'_>),
{
fn schema(&self) -> WidgetSchema {
let mut schema = WidgetSchema::new(
"VirtualList",
"A virtualized list for large datasets",
SemanticRole::Scrollable,
);
schema.usage_hint =
Some("VirtualList::new(24.0, |idx, rect, frame| { /* render item */ })".into());
schema.tags = vec![
"virtual".into(),
"list".into(),
"performance".into(),
"scroll".into(),
];
schema
}
fn capabilities(&self) -> Vec<AgentCapability> {
vec![
AgentCapability::Scrollable {
vertical: true,
horizontal: false,
},
AgentCapability::Focusable,
]
}
fn actions(&self) -> Vec<AgentAction> {
vec![
AgentAction::with_params(
"scroll_to",
"Scroll to make a specific item visible",
vec![ActionParam::required(
"index",
"Item index to scroll to",
ActionParamType::Integer,
)],
true,
),
AgentAction::simple(
"get_visible_range",
"Get the range of currently visible items",
false,
),
]
}
fn semantic_role(&self) -> SemanticRole {
SemanticRole::Scrollable
}
fn agent_state(&self) -> serde_json::Value {
serde_json::json!({
"item_height": self.item_height,
"overscan": self.overscan,
})
}
fn execute_action(
&mut self,
_action: &str,
_params: &serde_json::Value,
) -> Result<serde_json::Value, String> {
Err("Use StatefulWidget for state mutations".to_string())
}
fn agent_id(&self) -> Option<&str> {
if self.agent_id.is_empty() {
None
} else {
Some(&self.agent_id)
}
}
}
impl<F> StatefulWidget for VirtualList<F>
where
F: Fn(usize, Rect, &mut Frame<'_>),
{
type State = VirtualListState;
fn render(self, area: Rect, frame: &mut Frame<'_>, state: &mut VirtualListState) {
let total_content_height = state.total_items as f32 * self.item_height;
let max_scroll = (total_content_height - area.height).max(0.0);
state.scroll_offset = state.scroll_offset.clamp(0.0, max_scroll);
let range = Self::visible_range(
state.scroll_offset,
area.height,
self.item_height,
state.total_items,
self.overscan,
);
if !self.agent_id.is_empty() {
let node = UiNode::new("VirtualList", SemanticRole::Scrollable)
.with_id(&self.agent_id)
.with_bounds(area.into())
.with_property("total_items", serde_json::json!(state.total_items))
.with_property("scroll_offset", serde_json::json!(state.scroll_offset))
.with_property("visible_start", serde_json::json!(range.start))
.with_property("visible_end", serde_json::json!(range.end));
frame.register_widget(node);
frame.register_hitbox(&self.agent_id, area, 1);
}
for i in range {
let item_y = area.y + (i as f32 * self.item_height) - state.scroll_offset;
let item_rect = Rect::new(area.x, item_y, area.width, self.item_height);
if item_rect.y + item_rect.height > area.y && item_rect.y < area.y + area.height {
(self.render_item)(i, item_rect, frame);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn visible_range_basic() {
let range =
VirtualList::<fn(usize, Rect, &mut Frame<'_>)>::visible_range(0.0, 100.0, 20.0, 10, 0);
assert_eq!(range, 0..5);
}
#[test]
fn visible_range_scrolled() {
let range =
VirtualList::<fn(usize, Rect, &mut Frame<'_>)>::visible_range(40.0, 100.0, 20.0, 10, 0);
assert_eq!(range, 2..7);
}
#[test]
fn visible_range_with_overscan() {
let range =
VirtualList::<fn(usize, Rect, &mut Frame<'_>)>::visible_range(40.0, 100.0, 20.0, 10, 2);
assert_eq!(range, 0..9);
}
#[test]
fn visible_range_empty() {
let range =
VirtualList::<fn(usize, Rect, &mut Frame<'_>)>::visible_range(0.0, 100.0, 20.0, 0, 0);
assert_eq!(range, 0..0);
}
}