Skip to main content

rab/tui/components/
text.rs

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