rab/tui/components/
truncated_text.rs1use std::cell::RefCell;
2
3use crate::tui::Component;
4use crate::tui::util::{truncate_to_width, visible_width};
5
6pub 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 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 let empty_line = " ".repeat(width);
63 for _ in 0..self.padding_y {
64 result.push(empty_line.clone());
65 }
66
67 let single_line = match self.text.find('\n') {
69 Some(pos) => &self.text[..pos],
70 None => &self.text,
71 };
72
73 let available = width.saturating_sub(2 * self.padding_x).max(1);
75
76 let display = truncate_to_width(single_line, available, &self.ellipsis, false);
78
79 let left = " ".repeat(self.padding_x);
81 let padded = format!("{}{}", left, display);
82 let vw = visible_width(&padded);
83
84 let line = if vw < width {
86 format!("{}{}", padded, " ".repeat(width - vw))
87 } else {
88 padded
89 };
90 result.push(line);
91
92 for _ in 0..self.padding_y {
94 result.push(empty_line.clone());
95 }
96
97 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 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}