lv-tui 0.4.0

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

const BLOCKS: &[&str] = &[" ", "", "", "", "", "", "", "", ""];

/// A progress bar using Unicode 8-segment block characters.
///
/// Displays a bar of `width` cells filled to `ratio` (0.0–1.0). Purely visual —
/// not focusable, no keyboard interaction. Combine with `#[reactive]` and
/// [`Event::Tick`] for animated progress.
pub struct ProgressBar {
    ratio: f64,
    width: u16,
    style: Style,
    track_style: Style,
    label: Option<String>,
}

impl ProgressBar {
    /// Creates a new progress bar at 0%, 20 cells wide.
    pub fn new() -> Self {
        Self {
            ratio: 0.0,
            width: 20,
            style: Style::default().fg(crate::style::Color::Cyan),
            track_style: Style::default().fg(crate::style::Color::Gray),
            label: None,
        }
    }

    /// Builder: shows a percentage label (e.g. "75%").
    pub fn label(mut self, show: bool) -> Self {
        self.label = if show { Some(String::new()) } else { None };
        self
    }

    /// Builder: sets the fill ratio (0.0–1.0).
    pub fn ratio(mut self, ratio: f64) -> Self {
        self.ratio = ratio.clamp(0.0, 1.0);
        self
    }

    /// Builder: sets the bar width in character cells.
    pub fn width(mut self, width: u16) -> Self {
        self.width = width;
        self
    }

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

    /// Builder: sets the unfilled-portion (track) style.
    pub fn track_style(mut self, style: Style) -> Self {
        self.track_style = style;
        self
    }

    /// Sets the fill ratio and marks paint dirty.
    pub fn set_ratio(&mut self, ratio: f64, cx: &mut EventCx) {
        let r = ratio.clamp(0.0, 1.0);
        if (self.ratio - r).abs() > f64::EPSILON {
            self.ratio = r;
            cx.invalidate_paint();
        }
    }
}

impl Component for ProgressBar {
    fn render(&self, cx: &mut RenderCx) {
        let has_label = self.label.is_some();
        let bar_width = if has_label { self.width.saturating_sub(5) } else { self.width };

        let filled = (self.ratio * bar_width as f64) as u16;
        let whole = filled.min(bar_width);
        let frac = ((self.ratio * bar_width as f64) - whole as f64) * 8.0;
        let frac_idx = (frac as usize).min(BLOCKS.len() - 1);

        // Filled portion
        if whole > 0 {
            cx.set_style(self.style.clone());
            cx.text("".repeat(whole as usize));
        }

        // Fractional character + track
        if whole < bar_width {
            if frac_idx > 0 {
                cx.set_style(self.style.clone());
                cx.text(BLOCKS[frac_idx]);
            }
            let frac_used = if frac_idx > 0 { 1 } else { 0 };
            let remaining = bar_width.saturating_sub(whole).saturating_sub(frac_used);
            if remaining > 0 {
                cx.set_style(self.track_style.clone());
                cx.text("".repeat(remaining as usize));
            }
        }

        // Percentage label
        if has_label {
            let pct = format!(" {:3}%", (self.ratio * 100.0) as u8);
            cx.set_style(self.style.clone());
            cx.text(&pct);
        }

        cx.set_style(self.track_style.clone());
        cx.line("");
    }

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

    fn event(&mut self, _event: &Event, _cx: &mut EventCx) {}
    fn layout(&mut self, _rect: Rect, _cx: &mut crate::component::LayoutCx) {}
    fn focusable(&self) -> bool { false }
    fn style(&self) -> Style { self.style.clone() }
}

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

    #[test]
    fn test_empty() {
        let mut tb = TestBuffer::new(30, 1);
        tb.render(&ProgressBar::new().ratio(0.0).width(30));
        // 0% should render all track characters (░)
        let line = (0..30).map(|_| "").collect::<String>();
        tb.assert_line(0, &line);
    }

    #[test]
    fn test_full() {
        let mut tb = TestBuffer::new(30, 1);
        tb.render(&ProgressBar::new().ratio(1.0).width(30));
        let line = (0..30).map(|_| "").collect::<String>();
        tb.assert_line(0, &line);
    }

    #[test]
    fn test_with_label() {
        let mut tb = TestBuffer::new(30, 1);
        tb.render(&ProgressBar::new().ratio(0.5).width(30).label(true));
        // Should contain percentage text " 50%"
        assert!(tb.buffer.cells.iter().any(|c| c.symbol == "%"));
    }
}