lv-tui 0.2.0

A reactive TUI framework for Rust, inspired by Textual and React
Documentation
use crate::component::{Component, EventCx, MeasureCx};
use crate::event::Event;
use crate::geom::{Pos, Rect, Size};
use crate::layout::Constraint;
use crate::render::RenderCx;
use crate::style::{Color, Style};

/// A virtual-scrolling list for large datasets.
///
/// Only visible rows are rendered to the buffer. Rows are 1 line high.
/// Use Up/Down to scroll. Each item is rendered as a single line of text.
pub struct VirtualList {
    items: Vec<String>,
    scroll_y: u16,
    rect: Rect,
    style: Style,
    selected: Option<usize>,
    select_style: Style,
    show_numbers: bool,
}

impl VirtualList {
    /// Creates a virtual list with the given items.
    pub fn new(items: Vec<String>) -> Self {
        Self {
            items,
            scroll_y: 0,
            rect: Rect::default(),
            style: Style::default(),
            selected: None,
            select_style: Style::default().bg(Color::White).fg(Color::Black),
            show_numbers: false,
        }
    }

    /// Builder: sets the default row style.
    pub fn style(mut self, style: Style) -> Self { self.style = style; self }

    /// Builder: sets the selected row style.
    pub fn select_style(mut self, style: Style) -> Self { self.select_style = style; self }

    /// Builder: enables row numbering.
    pub fn show_numbers(mut self, show: bool) -> Self { self.show_numbers = show; self }

    /// Returns the currently selected item index.
    pub fn selected(&self) -> Option<usize> { self.selected }

    /// Sets the selected item.
    pub fn set_selected(&mut self, index: Option<usize>, cx: &mut EventCx) {
        self.selected = index;
        cx.invalidate_paint();
    }
}

impl Component for VirtualList {
    fn render(&self, cx: &mut RenderCx) {
        let vp = self.rect;
        if vp.height == 0 || self.items.is_empty() {
            return;
        }

        let visible_count = vp.height as usize;
        let max_scroll = self.items.len().saturating_sub(visible_count);
        let scroll = (self.scroll_y as usize).min(max_scroll);

        for i in 0..visible_count {
            let idx = scroll + i;
            if idx >= self.items.len() { break; }

            let row_y = vp.y.saturating_add(i as u16);
            let is_sel = self.selected == Some(idx);
            let s = if is_sel { &self.select_style } else { &self.style };

            let text = if self.show_numbers {
                format!("{:4}: {}", idx, self.items[idx])
            } else {
                self.items[idx].clone()
            };

            cx.buffer.write_text(
                Pos { x: vp.x, y: row_y }, vp, &text, s,
            );
        }
    }

    fn measure(&self, constraint: Constraint, _cx: &mut MeasureCx) -> Size {
        Size { width: constraint.max.width, height: constraint.max.height }
    }

    fn focusable(&self) -> bool { false }

    fn event(&mut self, event: &Event, cx: &mut EventCx) {
        if matches!(event, Event::Focus | Event::Blur) { return; }
        if self.items.is_empty() { return; }

        if let Event::Key(key_event) = event {
            let visible = self.rect.height.max(1) as usize;
            match &key_event.key {
                crate::event::Key::Up => {
                    if let Some(idx) = self.selected {
                        if idx > 0 {
                            self.selected = Some(idx - 1);
                            self.scroll_to_visible(idx - 1, visible);
                            cx.invalidate_paint();
                        }
                    }
                    return;
                }
                crate::event::Key::Down => {
                    let new_idx = match self.selected {
                        Some(i) if i + 1 < self.items.len() => i + 1,
                        None => 0,
                        _ => return,
                    };
                    self.selected = Some(new_idx);
                    self.scroll_to_visible(new_idx, visible);
                    cx.invalidate_paint();
                    return;
                }
                crate::event::Key::PageUp => {
                    let new_idx = match self.selected {
                        Some(i) => i.saturating_sub(visible),
                        None => 0,
                    };
                    self.selected = Some(new_idx);
                    self.scroll_y = self.scroll_y.saturating_sub(visible as u16);
                    self.scroll_to_visible(new_idx, visible);
                    cx.invalidate_paint();
                    return;
                }
                crate::event::Key::PageDown => {
                    let new_idx = match self.selected {
                        Some(i) => (i + visible).min(self.items.len() - 1),
                        None => (visible - 1).min(self.items.len() - 1),
                    };
                    self.selected = Some(new_idx);
                    self.scroll_to_visible(new_idx, visible);
                    cx.invalidate_paint();
                    return;
                }
                _ => {}
            }
        }
    }

    fn layout(&mut self, rect: Rect, _cx: &mut crate::component::LayoutCx) {
        self.rect = rect;
    }

    fn for_each_child(&self, _f: &mut dyn FnMut(&crate::node::Node)) {}
    fn for_each_child_mut(&mut self, _f: &mut dyn FnMut(&mut crate::node::Node)) {}
    fn style(&self) -> Style { self.style.clone() }
}

impl VirtualList {
    fn scroll_to_visible(&mut self, idx: usize, visible: usize) {
        if idx < self.scroll_y as usize {
            self.scroll_y = idx as u16;
        } else if idx >= self.scroll_y as usize + visible {
            self.scroll_y = (idx + 1).saturating_sub(visible) as u16;
        }
    }
}