aura-gui 0.3.5

UI nativa acelerada por GPU para escritorio, Android e iOS, en un solo crate: estilo tipo Tailwind, componentes, sombras, animaciones y agentes de IA. Reposo a cero. Licencia AGPL-3.0 o comercial.
//! Lista virtualizada de filas de igual altura: solo construye las filas visibles.

use crate::core::{Pixels, Point};
use crate::view::any_element::AnyElement;
use crate::view::context::ElementContext;
use crate::view::element::Element;
use crate::view::interactivity::{HandlerEntry, ScrollState};
use crate::view::style::{Direction, Length, Overflow, Style};
use crate::view::styled::Styled;
use std::cell::Cell;
use std::ops::Range;
use std::rc::Rc;
use taffy::NodeId;

/// Altura del área visible supuesta antes del primer layout.
const INITIAL_VIEWPORT: f32 = 400.0;

/// Desplazamiento de la lista; lo conserva la vista dueña entre frames.
#[derive(Clone)]
pub struct UniformListState(Rc<Cell<ScrollState>>);

impl Default for UniformListState {
    fn default() -> Self {
        Self(Rc::new(Cell::new(ScrollState { viewport_y: INITIAL_VIEWPORT, ..Default::default() })))
    }
}

impl UniformListState {
    pub fn visible_range(&self, count: usize, item_height: f32) -> Range<usize> {
        let state = self.0.get();
        let first = (state.offset_y / item_height).floor().max(0.0) as usize;
        let visible = (state.viewport_y / item_height).ceil() as usize + 2;
        first.min(count)..(first + visible).min(count)
    }

    /// Desplaza lo mínimo para que la fila `index` quede visible.
    pub fn scroll_to_item(&self, index: usize, item_height: f32) {
        let mut state = self.0.get();
        let top = index as f32 * item_height;
        if top < state.offset_y {
            state.offset_y = top;
        } else if top + item_height > state.offset_y + state.viewport_y {
            state.offset_y = top + item_height - state.viewport_y;
        }
        self.0.set(state);
    }

    pub fn reset(&self) {
        let mut state = self.0.get();
        state.offset_y = 0.0;
        self.0.set(state);
    }
}

pub struct UniformList {
    state: UniformListState,
    count: usize,
    item_height: f32,
    first: usize,
    items: Vec<AnyElement>,
    style: Style,
    node: Option<NodeId>,
}

/// `build` recibe el rango de filas visibles y devuelve un elemento por fila.
pub fn uniform_list(
    state: &UniformListState,
    count: usize,
    item_height: Pixels,
    build: impl FnOnce(Range<usize>) -> Vec<AnyElement>,
) -> UniformList {
    let range = state.visible_range(count, item_height.0);
    let style = Style { direction: Some(Direction::Column), overflow_y: Some(Overflow::Hidden), ..Default::default() };
    UniformList {
        state: state.clone(),
        count,
        item_height: item_height.0,
        first: range.start,
        items: build(range),
        style,
        node: None,
    }
}

impl Styled for UniformList {
    fn style(&mut self) -> &mut Style {
        &mut self.style
    }
}

impl Element for UniformList {
    fn request_layout(&mut self, cx: &mut ElementContext) -> NodeId {
        let row_style = Style {
            width: Some(Length::Percent(1.0)),
            height: Some(Length::Px(Pixels(self.item_height))),
            flex_shrink: Some(0.0),
            ..Default::default()
        };
        let rows: Vec<NodeId> = self
            .items
            .iter_mut()
            .map(|item| {
                let child = item.request_layout(cx);
                cx.request_node(&row_style, &[child])
            })
            .collect();
        let node = cx.request_node(&self.style, &rows);
        self.node = Some(node);
        node
    }

    fn prepaint(&mut self, cx: &mut ElementContext) {
        let Some(node) = self.node else { return };
        let bounds = cx.node_bounds(node);
        let mut state = self.state.0.get();
        state.viewport_y = bounds.size.height.0;
        state.max_y = (self.count as f32 * self.item_height - state.viewport_y).max(0.0);
        state.offset_y = state.offset_y.clamp(0.0, state.max_y);
        self.state.0.set(state);

        let hitbox = cx.insert_hitbox(bounds, false);
        cx.register_handlers(HandlerEntry { scroll: Some(self.state.0.clone()), ..HandlerEntry::new(hitbox) });
        let origin = self.content_origin(bounds.origin, state.offset_y);
        let height = self.item_height;
        cx.with_mask(bounds, |cx| for_each_row(&mut self.items, origin, height, cx, |item, cx| item.prepaint(cx)));
    }

    fn paint(&mut self, cx: &mut ElementContext) {
        let Some(node) = self.node else { return };
        let bounds = cx.node_bounds(node);
        let state = self.state.0.get();
        let origin = self.content_origin(bounds.origin, state.offset_y);
        let height = self.item_height;
        cx.with_mask(bounds, |cx| for_each_row(&mut self.items, origin, height, cx, |item, cx| item.paint(cx)));
        cx.scrollbar(bounds, &self.state.0, &[]);
    }
}

/// Cada fila se maqueta dentro de su envoltorio: su origen es el de la fila `index`.
fn for_each_row(
    items: &mut [AnyElement],
    origin: Point,
    height: f32,
    cx: &mut ElementContext,
    visit: fn(&mut AnyElement, &mut ElementContext),
) {
    for (index, item) in items.iter_mut().enumerate() {
        let row_origin = origin + Point::new(Pixels::ZERO, Pixels(index as f32 * height));
        cx.with_origin(row_origin, |cx| visit(item, cx));
    }
}

impl UniformList {
    /// Las filas se maquetan desde arriba; se desplazan según la primera fila construida.
    fn content_origin(&self, origin: Point, offset: f32) -> Point {
        origin + Point::new(Pixels::ZERO, Pixels(self.first as f32 * self.item_height - offset))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn range_covers_only_visible_rows() {
        let state = UniformListState::default();
        assert_eq!(state.visible_range(10_000, 20.0), 0..22);
        state.scroll_to_item(500, 20.0);
        let range = state.visible_range(10_000, 20.0);
        assert!(range.contains(&500) && range.len() <= 22);
    }
}