lv-tui 0.2.0

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

/// Column definition for a [`Table`].
#[derive(Default)]
pub struct TableColumn {
    /// Header text displayed in the column header row.
    pub title: String,
    /// Fixed width in character cells.
    pub width: u16,
    /// Text alignment within the column. Default: `TextAlign::Left`.
    pub align: crate::style::TextAlign,
}

/// A data table widget with column headers, row selection, and keyboard navigation.
///
/// Rows are provided as `Vec<Vec<String>>` — each inner `Vec` must match the
/// number of columns. Use [`Table::columns`] and [`Table::rows`] for
/// builder-style construction.
///
/// The table handles `Up`/`Down` keys internally for row selection. It is not
/// focusable in the Tab chain — keyboard events reach it through a parent
/// container's forwarding.
pub struct Table {
    columns: Vec<TableColumn>,
    rows: Vec<Vec<String>>,
    selected: Option<usize>,
    scroll_offset: usize,
    /// Current layout rect — updated on each layout pass.
    rect: Rect,
    style: Style,
    header_style: Style,
    select_style: Style,
}

impl Table {
    /// Creates an empty table.
    pub fn new() -> Self {
        Self {
            columns: Vec::new(),
            rows: Vec::new(),
            selected: None,
            scroll_offset: 0,
            rect: Rect::default(),
            style: Style::default(),
            header_style: Style::default().bold(),
            select_style: Style::default(),
        }
    }

    /// Sets the column definitions.
    pub fn columns(mut self, columns: Vec<TableColumn>) -> Self {
        self.columns = columns;
        self
    }

    /// Sets the row data.
    pub fn rows(mut self, rows: Vec<Vec<String>>) -> Self {
        self.rows = rows;
        self
    }

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

    /// Sets the header row style.
    pub fn header_style(mut self, style: Style) -> Self {
        self.header_style = style;
        self
    }

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

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

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

    /// Returns the number of rows.
    pub fn row_count(&self) -> usize {
        self.rows.len()
    }

    /// Sorts rows by the given column index, in ascending order.
    /// Invalid column index is a no-op.
    pub fn sort_by_column(&mut self, col: usize, cx: &mut EventCx) {
        if col < self.columns.len() {
            self.rows.sort_by(|a, b| {
                let ca = a.get(col).map(|s| s.as_str()).unwrap_or("");
                let cb = b.get(col).map(|s| s.as_str()).unwrap_or("");
                ca.cmp(cb)
            });
            cx.invalidate_paint();
        }
    }

    /// Sets the width of a specific column. Minimum width is 3.
    /// Invalid column index is a no-op.
    pub fn set_column_width(&mut self, col: usize, width: u16, cx: &mut EventCx) {
        if col < self.columns.len() {
            self.columns[col].width = width.max(3);
            cx.invalidate_layout();
        }
    }

    /// Adjusts the width of a column by `delta` (positive = wider).
    /// Invalid column index is a no-op.
    pub fn adjust_column_width(&mut self, col: usize, delta: i16, cx: &mut EventCx) {
        if col < self.columns.len() {
            let new = (self.columns[col].width as i16 + delta).max(3) as u16;
            self.columns[col].width = new;
            cx.invalidate_layout();
        }
    }

    /// Returns the number of visible rows that fit in `height`.
    fn visible_rows(&self, height: u16) -> usize {
        let usable = height.saturating_sub(2); // header + separator
        usable as usize
    }
}

impl Component for Table {
    fn render(&self, cx: &mut RenderCx) {
        let columns = &self.columns;
        if columns.is_empty() {
            return;
        }

        let col_count = columns.len();
        let visible = self.visible_rows(cx.rect.height);
        let start_row = self.scroll_offset;
        let end_row = (start_row + visible).min(self.rows.len());

        // --- header row ---
        cx.set_style(self.header_style.clone());
        for (i, col) in columns.iter().enumerate() {
            let text = truncate_to_width(&col.title, col.width, col.align);
            cx.text(&text);
            if i < col_count - 1 {
                cx.text("");
            }
        }
        cx.line("");

        // --- separator ---
        cx.set_style(self.style.clone());
        for (i, col) in columns.iter().enumerate() {
            let sep = "".repeat(col.width as usize);
            cx.text(&sep);
            if i < col_count - 1 {
                cx.text("");
            }
        }
        cx.line("");

        // --- data rows ---
        for row_idx in start_row..end_row {
            let is_selected = self.selected == Some(row_idx);
            if is_selected {
                cx.set_style(self.select_style.clone());
            } else {
                cx.set_style(self.style.clone());
            }

            let row = &self.rows[row_idx];
            for (i, col) in columns.iter().enumerate() {
                let cell_text = row.get(i).map(|s| s.as_str()).unwrap_or("");
                let text = truncate_to_width(cell_text, col.width, col.align);
                cx.text(&text);
                if i < col_count - 1 {
                    cx.text("");
                }
            }
            cx.line("");
        }
    }

    fn measure(&self, _constraint: Constraint, _cx: &mut MeasureCx) -> Size {
        let col_count = self.columns.len() as u16;
        if col_count == 0 {
            return Size { width: 0, height: 0 };
        }

        let width: u16 = self.columns.iter().map(|c| c.width).sum::<u16>()
            + col_count.saturating_sub(1); // separators

        let visible = self.rows.len().min(u16::MAX as usize) as u16;
        let height = 2u16.saturating_add(visible); // header + separator + rows

        Size { width, height }
    }

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

        if self.rows.is_empty() {
            return;
        }

        if let Event::Key(key_event) = event {
            match &key_event.key {
                crate::event::Key::Up => {
                    let new_idx = match self.selected {
                        Some(i) if i > 0 => i - 1,
                        _ => 0,
                    };
                    self.selected = Some(new_idx);
                    self.scroll_to_visible(new_idx);
                    cx.invalidate_paint();
                }
                crate::event::Key::Down => {
                    let max = self.rows.len() - 1;
                    let new_idx = match self.selected {
                        Some(i) if i < max => i + 1,
                        Some(i) => i,
                        None => 0,
                    };
                    self.selected = Some(new_idx);
                    self.scroll_to_visible(new_idx);
                    cx.invalidate_paint();
                }
                _ => {}
            }
        }
    }

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

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

    fn style(&self) -> Style {
        self.style.clone()
    }
}

impl Table {
    fn scroll_to_visible(&mut self, idx: usize) {
        let visible = self.visible_rows(self.rect.height);
        if visible == 0 {
            return;
        }
        if idx < self.scroll_offset {
            self.scroll_offset = idx;
        } else if idx >= self.scroll_offset + visible {
            self.scroll_offset = idx.saturating_sub(visible.saturating_sub(1));
        }
    }
}

/// Truncates and aligns `text` to fit within `max_width` character cells.
///
/// Takes Unicode width into account. Pads with spaces according to alignment.
fn truncate_to_width(text: &str, max_width: u16, align: crate::style::TextAlign) -> String {
    let mut result = String::new();
    let mut used: u16 = 0;
    for ch in text.chars() {
        let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0) as u16;
        if used + w > max_width {
            break;
        }
        used += w;
        result.push(ch);
    }
    let padding = max_width.saturating_sub(used);
    match align {
        crate::style::TextAlign::Left => {
            while used < max_width { result.push(' '); used += 1; }
        }
        crate::style::TextAlign::Center => {
            let left = padding / 2;
            let right = padding - left;
            let mut s = String::new();
            for _ in 0..left { s.push(' '); }
            s.push_str(&result);
            for _ in 0..right { s.push(' '); }
            result = s;
        }
        crate::style::TextAlign::Right => {
            let mut s = String::new();
            for _ in 0..padding { s.push(' '); }
            s.push_str(&result);
            result = s;
        }
    }
    result
}