lv-tui 0.3.0

A reactive TUI framework for Rust, inspired by Textual and React
Documentation
use crate::buffer::Buffer;
use crate::geom::{Pos, Rect, Size};
use crate::style::Style;
#[cfg(test)]
use crate::style::Color;

/// A styled text span — text content with a [`Style`].
///
/// Produced by the [`Stylize`](crate::stylize::Stylize) trait: `"hello".red().bold()`.
#[derive(Debug, Clone)]
pub struct Span {
    pub text: String,
    pub style: Style,
}

impl Span {
    /// Creates a plain span with default style.
    pub fn new(text: impl Into<String>) -> Self {
        Self { text: text.into(), style: Style::default() }
    }

    /// Creates a span with the given style.
    pub fn styled(text: impl Into<String>, style: Style) -> Self {
        Self { text: text.into(), style }
    }

    /// Unicode display width of this span.
    pub fn width(&self) -> u16 {
        self.text.chars()
            .map(|c| unicode_width::UnicodeWidthChar::width(c).unwrap_or(0) as u16)
            .sum()
    }
}

impl From<String> for Span {
    fn from(s: String) -> Self { Self { text: s, style: Style::default() } }
}
// No From<&str> for Span — would conflict with From<&str> for Text in widget APIs

/// A single line of styled text — a sequence of [`Span`]s.
#[derive(Debug, Clone)]
pub struct Line {
    pub spans: Vec<Span>,
}

impl Line {
    pub fn new(spans: impl IntoIterator<Item = Span>) -> Self {
        Self { spans: spans.into_iter().collect() }
    }

    /// Total Unicode display width of all spans.
    pub fn width(&self) -> u16 {
        self.spans.iter().map(Span::width).sum()
    }

    /// Write this line to the buffer at the given position.
    /// Returns the width written.
    pub fn render(&self, buf: &mut Buffer, pos: Pos, clip: Rect) -> u16 {
        let mut x = pos.x;
        for span in &self.spans {
            if span.text.is_empty() { continue; }
            buf.write_text(Pos { x, y: pos.y }, clip, &span.text, &span.style);
            x = x.saturating_add(span.width());
        }
        x.saturating_sub(pos.x)
    }
}

impl From<Span> for Line {
    fn from(span: Span) -> Self { Self { spans: vec![span] } }
}
// No From<&str> for Line — use Line::from(Span::new(...)) instead

impl From<Vec<Span>> for Line {
    fn from(spans: Vec<Span>) -> Self { Self { spans } }
}

/// Multi-line styled text.
#[derive(Debug, Clone)]
pub struct Text {
    pub lines: Vec<Line>,
}

impl Text {
    pub fn new(lines: impl IntoIterator<Item = Line>) -> Self {
        Self { lines: lines.into_iter().collect() }
    }

    /// Number of lines.
    pub fn height(&self) -> usize { self.lines.len() }

    /// Maximum width among all lines.
    pub fn max_width(&self) -> u16 {
        self.lines.iter().map(Line::width).max().unwrap_or(0)
    }

    /// The text of the first span of the first line, or "" if empty.
    pub fn first_text(&self) -> &str {
        self.lines.first().and_then(|l| l.spans.first()).map(|s| s.text.as_str()).unwrap_or("")
    }

    /// Write all lines to the buffer within the given rect.
    /// Returns the actual size used.
    pub fn render(&self, buf: &mut Buffer, rect: Rect) -> Size {
        for (i, line) in self.lines.iter().enumerate() {
            let y = rect.y.saturating_add(i as u16);
            if y >= rect.y.saturating_add(rect.height) { break; }
            line.render(buf, Pos { x: rect.x, y }, rect);
        }
        Size { width: self.max_width(), height: self.lines.len() as u16 }
    }
}

// From impls — make widget APIs accept &str/String/Line/Span directly

impl From<&str> for Text {
    fn from(s: &str) -> Self {
        if s.is_empty() { return Self { lines: Vec::new() }; }
        let lines: Vec<Line> = s.split('\n').map(|seg| Line::from(Span::new(seg))).collect();
        Self { lines }
    }
}

impl From<String> for Text {
    fn from(s: String) -> Self { Text::from(s.as_str()) }
}

impl From<Line> for Text {
    fn from(l: Line) -> Self { Self { lines: vec![l] } }
}

impl From<Vec<Line>> for Text {
    fn from(lines: Vec<Line>) -> Self { Self { lines } }
}

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

    // ── Span ──

    #[test]
    fn test_span_new() {
        let s = Span::new("hello");
        assert_eq!(s.text, "hello");
    }

    #[test]
    fn test_span_width() {
        assert_eq!(Span::new("abc").width(), 3);
    }

    // ── Line ──

    #[test]
    fn test_line_from_span() {
        let l = Line::from(Span::new("hello"));
        assert_eq!(l.spans.len(), 1);
        assert_eq!(l.spans[0].text, "hello");
    }

    #[test]
    fn test_line_from_spans() {
        let l = Line::from(vec![
            Span::styled("A", Style::default().fg(Color::Red)),
            Span::new("B"),
        ]);
        assert_eq!(l.spans.len(), 2);
        assert_eq!(l.width(), 2);
    }

    #[test]
    fn test_line_render() {
        let mut buf = Buffer::new(Size { width: 10, height: 1 });
        let line = Line::from(Span::new("hi"));
        let w = line.render(&mut buf, Pos::default(), Rect { x: 0, y: 0, width: 10, height: 1 });
        assert_eq!(w, 2);
        assert_eq!(buf.cells[0].symbol, "h");
    }

    // ── Text ──

    #[test]
    fn test_text_from_str() {
        let t = Text::from("hello");
        assert_eq!(t.lines.len(), 1);
    }

    #[test]
    fn test_text_from_line() {
        let t = Text::from(Line::from(Span::new("hello")));
        assert_eq!(t.lines.len(), 1);
    }

    #[test]
    fn test_text_from_vec() {
        let t = Text::from(vec![
            Line::from(Span::new("line1")),
            Line::from(Span::new("line2")),
        ]);
        assert_eq!(t.height(), 2);
    }

    #[test]
    fn test_text_with_stylize() {
        let t = Text::from(Line::from(vec![
            "Error: ".red().bold(),
            Span::new("not found"),
        ]));
        assert_eq!(t.lines[0].spans[0].style.fg, Some(crate::style::Color::Red));
        assert!(t.lines[0].spans[0].style.bold);
    }

    #[test]
    fn test_text_render() {
        let mut buf = Buffer::new(Size { width: 20, height: 2 });
        let t = Text::from(vec![
            Line::from(Span::new("hello")),
            Line::from(Span::new("world")),
        ]);
        t.render(&mut buf, Rect { x: 0, y: 0, width: 20, height: 2 });
        assert_eq!(buf.cells[0].symbol, "h");
        assert_eq!(&buf.cells[20].symbol, "w"); // second line at index 20
    }

    #[test]
    fn test_line_width_multi_style() {
        let line = Line::from(vec![
            Span::styled("AB", Style::default().fg(Color::Red)),
            Span::new("CD"),
        ]);
        assert_eq!(line.width(), 4);
    }
}