Skip to main content

rab/tui/components/
text.rs

1use crate::tui::Component;
2use crate::tui::Style;
3use crate::tui::util::{visible_width, wrap_text_with_ansi};
4
5/// Multi-line text component with word wrapping and padding.
6/// Port of pi's `packages/tui/src/components/text.ts`.
7pub struct Text {
8    content: String,
9    padding_x: usize,
10    padding_y: usize,
11    bg_style: Option<Style>,
12    // Render cache
13    cached_content: Option<String>,
14    cached_width: Option<usize>,
15    cached_lines: Vec<String>,
16}
17
18impl Text {
19    pub fn new(
20        content: impl Into<String>,
21        padding_x: usize,
22        padding_y: usize,
23        bg_style: Option<Style>,
24    ) -> Self {
25        Self {
26            content: content.into(),
27            padding_x,
28            padding_y,
29            bg_style,
30            cached_content: None,
31            cached_width: None,
32            cached_lines: Vec::new(),
33        }
34    }
35
36    pub fn set_text(&mut self, content: impl Into<String>) {
37        self.content = content.into();
38        self.invalidate();
39    }
40
41    pub fn set_bg_style(&mut self, bg_style: Option<Style>) {
42        self.bg_style = bg_style;
43        self.invalidate();
44    }
45}
46
47impl Component for Text {
48    fn render(&mut self, width: usize) -> Vec<String> {
49        // Check cache
50        if self.cached_content.as_deref() == Some(&self.content) && self.cached_width == Some(width)
51        {
52            return self.cached_lines.clone();
53        }
54
55        // Pi: return [] when content is empty or whitespace-only
56        if self.content.is_empty() || self.content.trim().is_empty() {
57            return Vec::new();
58        }
59
60        // Pi: replace tabs with 3 spaces
61        let normalized = self.content.replace('\t', "   ");
62
63        // Pi: max(1, width - paddingX * 2)
64        let content_width = width.saturating_sub(2 * self.padding_x).max(1);
65        let left_margin = " ".repeat(self.padding_x);
66
67        // Pi: wrap text (preserves ANSI, does NOT pad)
68        let wrapped = wrap_text_with_ansi(&normalized, content_width);
69
70        let mut content_lines: Vec<String> = Vec::new();
71        for line in wrapped {
72            let line_with_margins = format!("{}{}{}", left_margin, line, left_margin);
73            let vw = visible_width(&line_with_margins);
74            let padded = if vw < width {
75                format!("{}{}", line_with_margins, " ".repeat(width - vw))
76            } else {
77                line_with_margins
78            };
79            let line = match self.bg_style {
80                Some(ref style) => style.apply(&padded),
81                None => padded,
82            };
83            content_lines.push(line);
84        }
85
86        let empty_line = " ".repeat(width);
87        let empty_with_bg = self
88            .bg_style
89            .as_ref()
90            .map(|style| style.apply(&empty_line))
91            .unwrap_or_else(|| empty_line.clone());
92
93        let mut result = Vec::new();
94        for _ in 0..self.padding_y {
95            result.push(empty_with_bg.clone());
96        }
97        result.extend(content_lines);
98        for _ in 0..self.padding_y {
99            result.push(empty_with_bg.clone());
100        }
101
102        // Update cache
103        self.cached_content = Some(self.content.clone());
104        self.cached_width = Some(width);
105        self.cached_lines = result.clone();
106
107        result
108    }
109
110    fn invalidate(&mut self) {
111        self.cached_content = None;
112        self.cached_width = None;
113        self.cached_lines.clear();
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[test]
122    fn test_basic_render() {
123        let mut text = Text::new("hello", 1, 0, None);
124        let lines = text.render(20);
125        assert!(!lines.is_empty());
126        assert!(lines[0].contains("hello"));
127    }
128
129    #[test]
130    fn test_width_respected() {
131        let mut text = Text::new("hello world this is a long line", 1, 0, None);
132        let lines = text.render(10);
133        for line in &lines {
134            assert!(visible_width(line) <= 10);
135        }
136    }
137
138    #[test]
139    fn test_padding() {
140        let mut text = Text::new("hi", 2, 1, None);
141        let lines = text.render(10);
142        assert_eq!(lines.len(), 3);
143    }
144
145    #[test]
146    fn test_cache_hit() {
147        let mut text = Text::new("hello", 1, 0, None);
148        let a = text.render(20);
149        let b = text.render(20);
150        assert_eq!(a, b);
151    }
152}