Skip to main content

rab/tui/components/
truncated_text.rs

1use std::cell::RefCell;
2
3use crate::tui::Component;
4use crate::tui::util::{truncate_to_width, visible_width};
5
6/// Text truncated to fit within a maximum visible width with configurable ellipsis.
7/// Port of pi's `packages/tui/src/components/truncated-text.ts`.
8pub struct TruncatedText {
9    text: String,
10    ellipsis: String,
11    padding_x: usize,
12    padding_y: usize,
13    cached_width: RefCell<Option<usize>>,
14    cached_line: RefCell<String>,
15}
16
17impl TruncatedText {
18    pub fn new(text: impl Into<String>) -> Self {
19        Self {
20            text: text.into(),
21            ellipsis: "...".to_string(),
22            padding_x: 0,
23            padding_y: 0,
24            cached_width: RefCell::new(None),
25            cached_line: RefCell::new(String::new()),
26        }
27    }
28
29    pub fn with_ellipsis(mut self, ellipsis: impl Into<String>) -> Self {
30        self.ellipsis = ellipsis.into();
31        self
32    }
33
34    pub fn with_padding(mut self, padding_x: usize, padding_y: usize) -> Self {
35        self.padding_x = padding_x;
36        self.padding_y = padding_y;
37        self
38    }
39
40    pub fn set_text(&mut self, text: impl Into<String>) {
41        self.text = text.into();
42        *self.cached_width.borrow_mut() = None;
43    }
44
45    pub fn set_ellipsis(&mut self, ellipsis: impl Into<String>) {
46        self.ellipsis = ellipsis.into();
47        *self.cached_width.borrow_mut() = None;
48    }
49}
50
51impl Component for TruncatedText {
52    fn render(&self, width: usize) -> Vec<String> {
53        // Use cache for single-line no-padding case
54        if self.padding_x == 0 && self.padding_y == 0 && *self.cached_width.borrow() == Some(width)
55        {
56            return vec![self.cached_line.borrow().clone()];
57        }
58
59        let mut result: Vec<String> = Vec::new();
60
61        // Pi: vertical padding above
62        let empty_line = " ".repeat(width);
63        for _ in 0..self.padding_y {
64            result.push(empty_line.clone());
65        }
66
67        // Pi: only first line before newline is used
68        let single_line = match self.text.find('\n') {
69            Some(pos) => &self.text[..pos],
70            None => &self.text,
71        };
72
73        // Pi: calculate available width after horizontal padding
74        let available = width.saturating_sub(2 * self.padding_x).max(1);
75
76        // Pi: truncate with ellipsis
77        let display = truncate_to_width(single_line, available, &self.ellipsis, false);
78
79        // Pi: add horizontal padding
80        let left = " ".repeat(self.padding_x);
81        let padded = format!("{}{}", left, display);
82        let vw = visible_width(&padded);
83
84        // Pi: pad to full width
85        let line = if vw < width {
86            format!("{}{}", padded, " ".repeat(width - vw))
87        } else {
88            padded
89        };
90        result.push(line);
91
92        // Pi: vertical padding below
93        for _ in 0..self.padding_y {
94            result.push(empty_line.clone());
95        }
96
97        // Cache single-line no-padding case
98        if self.padding_x == 0 && self.padding_y == 0 {
99            *self.cached_width.borrow_mut() = Some(width);
100            *self.cached_line.borrow_mut() = if result.is_empty() {
101                String::new()
102            } else {
103                result[0].clone()
104            };
105        }
106
107        result
108    }
109
110    fn invalidate(&mut self) {
111        *self.cached_width.borrow_mut() = None;
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use crate::tui::util::visible_width;
119
120    #[test]
121    fn test_no_truncation() {
122        let tt = TruncatedText::new("hello");
123        let lines = tt.render(10);
124        // Pi: padded to full width
125        assert!(lines[0].starts_with("hello"));
126        assert_eq!(crate::tui::util::visible_width(&lines[0]), 10);
127    }
128
129    #[test]
130    fn test_truncated() {
131        let tt = TruncatedText::new("hello world");
132        let lines = tt.render(8);
133        assert!(visible_width(&lines[0]) <= 8);
134        assert!(lines[0].contains("..."));
135    }
136
137    #[test]
138    fn test_padding() {
139        let tt = TruncatedText::new("hello").with_padding(1, 1);
140        let lines = tt.render(10);
141        assert_eq!(lines.len(), 3, "Should have top pad + line + bottom pad");
142        assert!(
143            lines[0].chars().all(|c| c == ' '),
144            "Top padding should be spaces"
145        );
146        assert!(lines[1].contains("hello"), "Content should contain text");
147        assert!(
148            lines[2].chars().all(|c| c == ' '),
149            "Bottom padding should be spaces"
150        );
151    }
152
153    #[test]
154    fn test_only_first_line() {
155        let tt = TruncatedText::new("line1\nline2");
156        let lines = tt.render(20);
157        assert_eq!(lines.len(), 1);
158        assert!(
159            !lines[0].contains("line2"),
160            "Should not contain second line"
161        );
162    }
163}