aura-gui 0.2.2

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 alto variable, como `list` de GPUI.
//!
//! Las alturas se aprenden al maquetar: cada elemento visible se mide tras el
//! layout y los que nunca se vieron usan una estimación. El scroll se ancla al
//! elemento superior (índice y desplazamiento dentro de él), así que cuando una
//! medida reemplaza a su estimación lo que se está viendo no salta.

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, SharedScroll};
use crate::view::style::{Direction, Length, Overflow, Style};
use crate::view::styled::Styled;
use std::cell::{Cell, RefCell};
use std::ops::Range;
use std::rc::Rc;
use taffy::NodeId;

/// Se construye un poco más allá del borde para que el scroll no muestre huecos.
const OVERDRAW: f32 = 100.0;
const INITIAL_VIEWPORT: f32 = 400.0;

struct ListInner {
    heights: Vec<Option<f32>>,
    estimate: f32,
    scroll: SharedScroll,
    /// Elemento superior y cuánto de él queda por encima del borde.
    anchor: (usize, f32),
    /// Último desplazamiento aplicado; si la rueda lo cambió, se recalcula el ancla.
    applied_offset: f32,
    /// Con `follow_end`, el final queda a la vista mientras el usuario no suba (como una terminal).
    sticky: bool,
    pinned: bool,
    /// Hueco sobre el primer elemento cuando todo cabe y la lista sigue el final: el contenido queda abajo.
    gap_above: f32,
}

impl ListInner {
    fn height(&self, index: usize) -> f32 {
        self.heights[index].unwrap_or(self.estimate)
    }

    fn offset_of(&self, index: usize) -> f32 {
        (0..index.min(self.heights.len())).map(|index| self.height(index)).sum()
    }

    /// Elemento que contiene el desplazamiento `offset` y la distancia desde su borde superior.
    fn locate(&self, offset: f32) -> (usize, f32) {
        let mut top = 0.0;
        for index in 0..self.heights.len() {
            let height = self.height(index);
            if top + height > offset {
                return (index, offset - top);
            }
            top += height;
        }
        (self.heights.len().saturating_sub(1), 0.0)
    }
}

/// Estado de la lista; lo conserva la vista dueña entre frames.
#[derive(Clone)]
pub struct ListState(Rc<RefCell<ListInner>>);

impl ListState {
    pub fn new(count: usize, estimated_height: Pixels) -> Self {
        let scroll = Rc::new(Cell::new(ScrollState { viewport_y: INITIAL_VIEWPORT, ..Default::default() }));
        Self(Rc::new(RefCell::new(ListInner {
            heights: vec![None; count],
            estimate: estimated_height.0,
            scroll,
            anchor: (0, 0.0),
            applied_offset: 0.0,
            sticky: false,
            pinned: false,
            gap_above: 0.0,
        })))
    }

    pub fn item_count(&self) -> usize {
        self.0.borrow().heights.len()
    }

    /// Reemplaza los elementos de `range` por `inserted` nuevos (aún sin medir).
    /// El ancla se mueve con su elemento para que la vista no salte.
    pub fn splice(&self, range: Range<usize>, inserted: usize) {
        let mut inner = self.0.borrow_mut();
        let range = range.start.min(inner.heights.len())..range.end.min(inner.heights.len());
        inner.heights.splice(range.clone(), std::iter::repeat_n(None, inserted));
        let (top, within) = inner.anchor;
        if top >= range.end {
            inner.anchor = (top + inserted - range.len(), within);
        } else if top >= range.start {
            inner.anchor = (range.start, 0.0);
        }
    }

    /// Lleva el elemento `index` al borde superior.
    pub fn scroll_to_item(&self, index: usize) {
        let mut inner = self.0.borrow_mut();
        inner.anchor = (index.min(inner.heights.len().saturating_sub(1)), 0.0);
    }

    /// Muestra el final y lo sigue al crecer, como una terminal: si todo cabe, queda pegado abajo.
    /// Subir con la rueda lo suelta y volver abajo lo retoma.
    pub fn follow_end(&self) {
        let mut inner = self.0.borrow_mut();
        (inner.sticky, inner.pinned) = (true, true);
    }

    /// `true` si el final está a la vista y la lista lo sigue.
    pub fn is_following_end(&self) -> bool {
        self.0.borrow().pinned
    }

    /// Altura medida del elemento, si ya se vio alguna vez.
    pub fn measured_height(&self, index: usize) -> Option<f32> {
        self.0.borrow().heights.get(index).copied().flatten()
    }

    /// Índices a construir: del ancla hacia abajo hasta cubrir el área visible.
    fn visible_range(&self) -> Range<usize> {
        let mut inner = self.0.borrow_mut();
        let scroll = inner.scroll.get();
        if scroll.offset_y != inner.applied_offset {
            inner.anchor = inner.locate(scroll.offset_y);
            inner.pinned = inner.sticky && scroll.offset_y >= scroll.max_y - 1.0;
        }
        let count = inner.heights.len();
        if inner.pinned {
            // Desde el final hacia arriba hasta cubrir el área visible.
            let (mut first, mut covered) = (count, 0.0);
            while first > 0 && covered < scroll.viewport_y + OVERDRAW {
                first -= 1;
                covered += inner.height(first);
            }
            return first..count;
        }
        let first = inner.anchor.0.min(inner.heights.len());
        let mut covered = -inner.anchor.1;
        let mut end = first;
        while end < inner.heights.len() && covered < scroll.viewport_y + OVERDRAW {
            covered += inner.height(end);
            end += 1;
        }
        first..end
    }
}

pub struct List {
    state: ListState,
    first: usize,
    items: Vec<AnyElement>,
    wrappers: Vec<NodeId>,
    style: Style,
    node: Option<NodeId>,
}

/// `build` recibe el índice de cada elemento visible y devuelve su elemento.
pub fn list(state: &ListState, build: impl FnMut(usize) -> AnyElement) -> List {
    let range = state.visible_range();
    let style = Style { direction: Some(Direction::Column), overflow_y: Some(Overflow::Hidden), ..Default::default() };
    List {
        state: state.clone(),
        first: range.start,
        items: range.map(build).collect(),
        wrappers: Vec::new(),
        style,
        node: None,
    }
}

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

impl List {
    /// Guarda las alturas reales y fija el scroll a partir del ancla. `true` si alguna cambió.
    fn measure(&self, cx: &ElementContext, viewport: f32) -> bool {
        let mut inner = self.state.0.borrow_mut();
        let mut changed = false;
        for (offset, wrapper) in self.wrappers.iter().enumerate() {
            let height = Some(cx.node_bounds(*wrapper).size.height.0);
            let slot = &mut inner.heights[self.first + offset];
            changed |= *slot != height;
            *slot = height;
        }
        let total = inner.offset_of(inner.heights.len());
        let max_y = (total - viewport).max(0.0);
        let anchored = inner.offset_of(inner.anchor.0) + inner.anchor.1;
        let offset = if inner.pinned { max_y } else { anchored.clamp(0.0, max_y) };
        inner.anchor = inner.locate(offset);
        inner.applied_offset = offset;
        inner.gap_above = if inner.sticky { (viewport - total).max(0.0) } else { 0.0 };
        let grab = inner.scroll.get().grab;
        inner.scroll.set(ScrollState { offset_y: offset, max_y, viewport_y: viewport, grab });
        changed
    }

    /// Visita cada elemento en su posición, desplazada por lo que el ancla deja arriba.
    fn visit(&mut self, cx: &mut ElementContext, visit: fn(&mut AnyElement, &mut ElementContext)) {
        let Some(node) = self.node else { return };
        let bounds = cx.node_bounds(node);
        // El primer elemento construido empieza en `offset_of(first)`; se sube hasta el scroll aplicado.
        let shift = {
            let inner = self.state.0.borrow();
            inner.applied_offset - inner.offset_of(self.first) - inner.gap_above
        };
        let origin = bounds.origin - Point::new(Pixels::ZERO, Pixels(shift));
        let wrappers = &self.wrappers;
        let items = &mut self.items;
        cx.with_mask(bounds, |cx| {
            cx.with_origin(origin, |cx| {
                for (item, wrapper) in items.iter_mut().zip(wrappers) {
                    let at = cx.node_bounds(*wrapper).origin;
                    cx.with_origin(at, |cx| visit(item, cx));
                }
            })
        });
    }
}

impl Element for List {
    fn request_layout(&mut self, cx: &mut ElementContext) -> NodeId {
        let wrapper_style = Style { width: Some(Length::Percent(1.0)), flex_shrink: Some(0.0), ..Default::default() };
        self.wrappers = self
            .items
            .iter_mut()
            .map(|item| {
                let child = item.request_layout(cx);
                cx.request_node(&wrapper_style, &[child])
            })
            .collect();
        let node = cx.request_node(&self.style, &self.wrappers);
        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);
        // Las medidas nuevas cambian qué cabe en pantalla: se pide otro frame para corregirlo.
        if self.measure(cx, bounds.size.height.0) {
            cx.request_animation_frame();
        }
        let hitbox = cx.insert_hitbox(bounds, false);
        let scroll = self.state.0.borrow().scroll.clone();
        cx.register_handlers(HandlerEntry { scroll: Some(scroll), ..HandlerEntry::new(hitbox) });
        self.visit(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);
        self.visit(cx, |item, cx| item.paint(cx));
        let scroll = self.state.0.borrow().scroll.clone();
        cx.scrollbar(bounds, &scroll, &[]);
    }
}