bettertui_engine 0.1.0

High-performance terminal UI framework
Documentation
use crate::tree::Style;

use super::unicode;

#[derive(Debug, Clone, PartialEq)]
pub struct StyledSpan {
    pub text: String,
    pub style: Style,
}

impl StyledSpan {
    pub fn new(text: impl Into<String>) -> Self {
        Self { text: text.into(), style: Style::default() }
    }

    pub fn styled(text: impl Into<String>, style: Style) -> Self {
        Self { text: text.into(), style }
    }

    pub fn display_width(&self) -> usize {
        unicode::display_width(&self.text)
    }
}

#[derive(Debug, Clone, PartialEq, Default)]
pub struct StyledText {
    pub spans: Vec<StyledSpan>,
}

impl StyledText {
    pub fn new() -> Self {
        Self { spans: Vec::new() }
    }

    pub fn with_capacity(cap: usize) -> Self {
        Self { spans: Vec::with_capacity(cap) }
    }

    pub fn push(&mut self, span: StyledSpan) {
        if let Some(last) = self.spans.last_mut()
            && last.style == span.style
        {
            last.text.push_str(&span.text);
            return;
        }
        self.spans.push(span);
    }

    pub fn push_text(&mut self, text: impl Into<String>) {
        let text = text.into();
        if text.is_empty() {
            return;
        }
        if let Some(last) = self.spans.last_mut()
            && last.style.is_empty()
        {
            last.text.push_str(&text);
            return;
        }
        self.spans.push(StyledSpan::new(text));
    }

    pub fn push_styled(&mut self, text: impl Into<String>, style: Style) {
        self.push(StyledSpan::styled(text, style));
    }

    pub fn is_empty(&self) -> bool {
        self.spans.is_empty() || self.spans.iter().all(|s| s.text.is_empty())
    }

    pub fn plain_text(&self) -> String {
        let capacity = self.spans.iter().map(|s| s.text.len()).sum();
        let mut result = String::with_capacity(capacity);
        for span in &self.spans {
            result.push_str(&span.text);
        }
        result
    }

    pub fn display_width(&self) -> usize {
        self.spans.iter().map(|s| s.display_width()).sum()
    }

    pub fn merge_adjacent_with_same_style(&mut self) {
        if self.spans.len() < 2 {
            return;
        }
        let mut merged = Vec::with_capacity(self.spans.len());
        let mut current = self.spans[0].clone();
        for span in self.spans.drain(1..) {
            if current.style == span.style {
                current.text.push_str(&span.text);
            } else {
                merged.push(current);
                current = span;
            }
        }
        merged.push(current);
        self.spans = merged;
    }

    pub fn split_at(&self, byte_offset: usize) -> (StyledText, StyledText) {
        let mut left = StyledText::new();
        let mut right = StyledText::new();
        let mut pos = 0;
        for span in &self.spans {
            let span_end = pos + span.text.len();
            if byte_offset <= pos {
                right.spans.push(span.clone());
            } else if byte_offset >= span_end {
                left.spans.push(span.clone());
            } else {
                let split_point = byte_offset - pos;
                let (l, r) = span.text.split_at(split_point);
                left.spans.push(StyledSpan::styled(l.to_string(), span.style));
                right.spans.push(StyledSpan::styled(r.to_string(), span.style));
            }
            pos = span_end;
        }
        (left, right)
    }

    pub fn subspan(&self, start: usize, end: usize) -> StyledText {
        let (_, after_left) = self.split_at(start);
        let (result, _) = after_left.split_at(end - start);
        result
    }

    pub fn truncate_to_width(&self, max_width: usize) -> StyledText {
        let mut result = StyledText::new();
        let mut col = 0;
        for span in &self.spans {
            if col >= max_width {
                break;
            }
            let remaining = max_width - col;
            let span_w = span.display_width();
            if span_w <= remaining {
                result.push(span.clone());
                col += span_w;
            } else {
                let truncated = unicode::truncate_to_width(&span.text, remaining);
                if !truncated.is_empty() {
                    result.push(StyledSpan::styled(truncated.to_string(), span.style));
                }
                break;
            }
        }
        result
    }
}

impl From<&str> for StyledText {
    fn from(s: &str) -> Self {
        let mut ss = StyledText::new();
        ss.push_text(s);
        ss
    }
}

impl From<String> for StyledText {
    fn from(s: String) -> Self {
        let mut ss = StyledText::new();
        ss.push_text(s);
        ss
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::tree::{Color, NamedColor};

    #[test]
    fn styled_span_new() {
        let span = StyledSpan::new("hello");
        assert_eq!(span.text, "hello");
        assert!(span.style.is_empty());
    }

    #[test]
    fn styled_span_styled() {
        let style = Style { bold: Some(true), ..Style::default() };
        let span = StyledSpan::styled("bold", style);
        assert!(span.style.bold.unwrap());
    }

    #[test]
    fn styled_string_push_merges_adjacent() {
        let mut ss = StyledText::new();
        ss.push_text("hello ");
        ss.push_text("world");
        assert_eq!(ss.spans.len(), 1);
        assert_eq!(ss.plain_text(), "hello world");
    }

    #[test]
    fn styled_string_push_no_merge_different_style() {
        let mut ss = StyledText::new();
        ss.push_styled("bold", Style { bold: Some(true), ..Style::default() });
        ss.push_text("normal");
        assert_eq!(ss.spans.len(), 2);
    }

    #[test]
    fn styled_string_plain_text() {
        let mut ss = StyledText::new();
        ss.push_styled("hello", Style { fg: Some(Color::Named(NamedColor::Red)), ..Style::default() });
        ss.push_text(" world");
        assert_eq!(ss.plain_text(), "hello world");
    }

    #[test]
    fn styled_string_display_width() {
        let mut ss = StyledText::new();
        ss.push_text("hello");
        ss.push_text(" \u{4e2d}\u{6587}");
        assert_eq!(ss.display_width(), 10);
    }

    #[test]
    fn styled_string_is_empty() {
        let ss = StyledText::new();
        assert!(ss.is_empty());
    }

    #[test]
    fn styled_string_merge_adjacent() {
        let mut ss = StyledText::new();
        let style = Style { bold: Some(true), ..Style::default() };
        ss.push_styled("hello", style);
        ss.push_styled(" world", Style { bold: Some(true), ..Style::default() });
        ss.merge_adjacent_with_same_style();
        assert_eq!(ss.spans.len(), 1);
        assert_eq!(ss.plain_text(), "hello world");
    }

    #[test]
    fn styled_string_split_at() {
        let mut ss = StyledText::new();
        ss.push_styled("hello", Style { bold: Some(true), ..Style::default() });
        ss.push_text(" world");
        let (left, right) = ss.split_at(5);
        assert_eq!(left.plain_text(), "hello");
        assert_eq!(right.plain_text(), " world");
    }

    #[test]
    fn styled_string_subspan() {
        let mut ss = StyledText::new();
        ss.push_text("hello world");
        let sub = ss.subspan(0, 5);
        assert_eq!(sub.plain_text(), "hello");
    }

    #[test]
    fn styled_string_truncate_to_width() {
        let mut ss = StyledText::new();
        ss.push_text("hello world");
        let truncated = ss.truncate_to_width(5);
        assert_eq!(truncated.plain_text(), "hello");
    }

    #[test]
    fn styled_string_from_str() {
        let ss: StyledText = "hello".into();
        assert_eq!(ss.plain_text(), "hello");
    }

    #[test]
    fn styled_string_from_string() {
        let ss: StyledText = String::from("hello").into();
        assert_eq!(ss.plain_text(), "hello");
    }

    #[test]
    fn styled_string_with_capacity() {
        let ss = StyledText::with_capacity(10);
        assert!(ss.is_empty());
    }
}